Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 42 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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**.
Expand Down Expand Up @@ -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`:
Expand Down
78 changes: 75 additions & 3 deletions docking/core/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@

import enum
import json
import os
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
Expand All @@ -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"
Expand Down Expand Up @@ -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 ---
Expand Down
4 changes: 2 additions & 2 deletions docking/ui/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 65 additions & 1 deletion tests/core/test_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"),
[
Expand Down
Loading