omarchy-dms 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ """Omarchy themes as DMS profiles.
2
+
3
+ Library first: `ops.Omarchy` is the API (no printing, no exiting, progress goes
4
+ to a `log` callback); `cli` is one thin frontend over it. Other frontends import
5
+ this package instead of shelling out to the CLI.
6
+ """
7
+
8
+ from .dms import Dms, DmsError
9
+ from .ops import Omarchy, Row, store_repos
10
+ from .store import Store, StoreError, Theme, resolve_repo
11
+ from .theme import ThemeError
12
+
13
+ __all__ = [
14
+ "Dms",
15
+ "DmsError",
16
+ "Omarchy",
17
+ "Row",
18
+ "Store",
19
+ "StoreError",
20
+ "Theme",
21
+ "ThemeError",
22
+ "resolve_repo",
23
+ "store_repos",
24
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
omarchy_dms/cli.py ADDED
@@ -0,0 +1,92 @@
1
+ """Command line frontend over ops.Omarchy."""
2
+
3
+ import argparse
4
+ import os
5
+ import shlex
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from . import theme
12
+ from .dms import Dms, DmsError
13
+ from .ops import Omarchy, store_repos
14
+ from .store import Store, StoreError
15
+
16
+ HOME = Path.home()
17
+ THEMES = HOME / ".config/DankMaterialShell/themes"
18
+ WALLPAPERS = HOME / "Pictures/Wallpapers/omarchy"
19
+
20
+
21
+ def _hook_command():
22
+ exe = shutil.which("omarchy-dms")
23
+ return f"{shlex.quote(exe)} hook" if exe else f"{shlex.quote(sys.executable)} -m omarchy_dms hook"
24
+
25
+
26
+ def _pick(items, prompt, multi):
27
+ if not shutil.which("fzf"):
28
+ raise StoreError("fzf not found: pass the name(s) as arguments")
29
+ cmd = ["fzf", "--prompt", prompt, *(["-m"] if multi else [])]
30
+ r = subprocess.run(cmd, input="\n".join(items), text=True, stdout=subprocess.PIPE, check=False)
31
+ return r.stdout.split()
32
+
33
+
34
+ def _parser():
35
+ p = argparse.ArgumentParser(prog="omarchy-dms", description="Omarchy themes as DMS profiles.")
36
+ sub = p.add_subparsers(dest="cmd", required=True)
37
+ s = sub.add_parser("sync", help="create profiles for Omarchy's own themes (all, or the ones named)")
38
+ s.add_argument("--force", action="store_true", help="recreate the ones that exist")
39
+ s.add_argument("themes", nargs="*")
40
+ s = sub.add_parser("install", help="install a community theme and create its profile")
41
+ s.add_argument("repo", nargs="?", help="owner/repo or git URL (none: pick from omarchy.org/themes)")
42
+ s = sub.add_parser("update", help="refresh themes installed with install (all, or the ones named)")
43
+ s.add_argument("themes", nargs="*")
44
+ s = sub.add_parser("remove", help="remove theme, wallpapers and profile (none named: pick with fzf)")
45
+ s.add_argument("-y", "--yes", action="store_true", help="do not ask")
46
+ s.add_argument("themes", nargs="*")
47
+ sub.add_parser("installed", help="every theme, where it comes from, profile or not")
48
+ sub.add_parser("list", help="repos listed on omarchy.org/themes")
49
+ s = sub.add_parser("render", help="convert a colors.toml into a DMS theme folder")
50
+ s.add_argument("colors")
51
+ s.add_argument("slug")
52
+ s.add_argument("outdir")
53
+ sub.add_parser("hook", help="dmsProfiles apply command (reads DMS_PROFILE_SLUG)")
54
+ return p
55
+
56
+
57
+ def main(argv=None):
58
+ args = _parser().parse_args(argv)
59
+ builtin = Path(os.environ["OMARCHY_THEMES"]) if "OMARCHY_THEMES" in os.environ else None
60
+ omarchy = Omarchy(Dms(), Store(THEMES, WALLPAPERS), builtin, _hook_command())
61
+ try:
62
+ match args.cmd:
63
+ case "sync":
64
+ omarchy.sync(args.themes, args.force)
65
+ case "install":
66
+ repo = args.repo or next(iter(_pick(store_repos(), "omarchy theme> ", False)), None)
67
+ if repo:
68
+ omarchy.install(repo)
69
+ case "update":
70
+ return 1 if omarchy.update(args.themes) else 0
71
+ case "remove":
72
+ names = args.themes or _pick(omarchy.store.slugs(), "remove (Tab marks)> ", True)
73
+ if names and not args.yes:
74
+ print(f"remove: {' '.join(names)}")
75
+ if input("continue? [y/N] ") != "y":
76
+ return 0
77
+ if names:
78
+ omarchy.remove(names)
79
+ case "installed":
80
+ for row in omarchy.installed():
81
+ origin = row.origin or "community (no .source: reinstall to track)"
82
+ print(f"{row.slug:<22} {'profile' if row.profile else '-':<8} {origin}")
83
+ case "list":
84
+ print("\n".join(store_repos()))
85
+ case "render":
86
+ theme.render(args.colors, args.slug, args.outdir)
87
+ case "hook":
88
+ omarchy.hook(os.environ.get("DMS_PROFILE_SLUG", ""))
89
+ except (DmsError, StoreError, theme.ThemeError) as e:
90
+ print(f"error: {e}", file=sys.stderr)
91
+ return 1
92
+ return 0
omarchy_dms/dms.py ADDED
@@ -0,0 +1,132 @@
1
+ """Thin client for the DMS shell over `dms ipc`.
2
+
3
+ `runner` is the only thing that touches the process table, so a frontend or a
4
+ test can swap it. Calls the plugin finishes asynchronously (create, remove,
5
+ apply) are polled until the shell reports the result, instead of sleeping.
6
+ """
7
+
8
+ import contextlib
9
+ import json
10
+ import subprocess
11
+ import time
12
+ from collections.abc import Callable
13
+
14
+ Runner = Callable[[list[str]], str]
15
+
16
+
17
+ class DmsError(RuntimeError):
18
+ pass
19
+
20
+
21
+ def run_dms(args):
22
+ try:
23
+ p = subprocess.run(["dms", *args], capture_output=True, text=True, check=False)
24
+ except FileNotFoundError as e:
25
+ raise DmsError("dms not found in PATH") from e
26
+ if p.returncode:
27
+ raise DmsError(f"dms {' '.join(args)}: {p.stderr.strip() or p.stdout.strip()}")
28
+ return p.stdout.strip()
29
+
30
+
31
+ class Dms:
32
+ def __init__(
33
+ self,
34
+ runner: Runner = run_dms,
35
+ sleep=time.sleep,
36
+ attempts=60,
37
+ interval=0.25,
38
+ spawn: Callable[..., object] = subprocess.Popen,
39
+ ):
40
+ self._run = runner
41
+ self._sleep = sleep
42
+ self.attempts = attempts
43
+ self.interval = interval
44
+ self._spawn = spawn
45
+
46
+ def ipc(self, *args):
47
+ return self._run(["ipc", "call", *args])
48
+
49
+ def _json(self, *args):
50
+ out = self.ipc(*args)
51
+ try:
52
+ return json.loads(out or "null")
53
+ except json.JSONDecodeError as e:
54
+ raise DmsError(f"dms ipc call {' '.join(args)}: not JSON: {out[:80]!r}") from e
55
+
56
+ def _wait(self, what, ok):
57
+ for _ in range(self.attempts):
58
+ if ok():
59
+ return
60
+ self._sleep(self.interval)
61
+ raise DmsError(f"timed out waiting for {what}")
62
+
63
+ # -- reading
64
+ def profiles(self):
65
+ return self._json("profiles", "list") or []
66
+
67
+ def find_profile(self, slug, name):
68
+ return next((p for p in self.profiles() if p.get("slug") == slug or p.get("name") == name), None)
69
+
70
+ def status(self):
71
+ return self._json("profiles", "status") or {}
72
+
73
+ def active(self):
74
+ return self.status().get("active", "")
75
+
76
+ def snapshot(self):
77
+ return self._json("profiles", "snapshot") or {}
78
+
79
+ # -- profiles
80
+ def set_option(self, key, value):
81
+ self.ipc("profiles", "setOption", key, value)
82
+
83
+ @contextlib.contextmanager
84
+ def autosave_paused(self):
85
+ """Autosave would write each theme change into the previously active profile."""
86
+ self.set_option("autosave", "false")
87
+ try:
88
+ yield
89
+ finally:
90
+ self.set_option("autosave", "true")
91
+
92
+ def create_profile(self, name):
93
+ self.ipc("profiles", "create", name)
94
+ self._wait(f"profile {name!r}", lambda: any(p.get("name") == name for p in self.profiles()))
95
+
96
+ def remove_profile(self, slug):
97
+ self.ipc("profiles", "remove", slug)
98
+ self._wait(f"profile {slug!r} to go", lambda: all(p.get("slug") != slug for p in self.profiles()))
99
+
100
+ def apply_profile(self, slug):
101
+ self.ipc("profiles", "apply", slug)
102
+
103
+ def done():
104
+ s = self.status()
105
+ return s.get("active") == slug and not s.get("switching")
106
+
107
+ self._wait(f"profile {slug!r} to apply", done)
108
+
109
+ # -- the look itself
110
+ def custom_theme(self, theme_json, theme_id):
111
+ self.ipc("profiles", "customTheme", str(theme_json), theme_id, "default", "default", "default", "both")
112
+ self._wait("theme", lambda: self.snapshot().get("settings", {}).get("customThemeFile") == str(theme_json))
113
+
114
+ def set_mode(self, mode):
115
+ self.ipc("theme", mode)
116
+ self._wait(f"{mode} mode", lambda: self.snapshot().get("session", {}).get("isLightMode") == (mode == "light"))
117
+
118
+ def set_wallpaper(self, path):
119
+ out = self.ipc("wallpaper", "set", str(path))
120
+ if out.startswith("ERROR"):
121
+ raise DmsError(out)
122
+ self._wait("wallpaper", lambda: self.snapshot().get("session", {}).get("wallpaperPath") == str(path))
123
+
124
+ def set_wallpaper_later(self, path, delay=2):
125
+ """Detached, so the caller (dmsProfiles' apply command) returns at once."""
126
+ self._spawn(
127
+ ["sh", "-c", 'sleep "$1"; exec dms ipc call wallpaper set "$2"', "_", str(delay), str(path)],
128
+ start_new_session=True,
129
+ stdin=subprocess.DEVNULL,
130
+ stdout=subprocess.DEVNULL,
131
+ stderr=subprocess.DEVNULL,
132
+ )
omarchy_dms/ops.py ADDED
@@ -0,0 +1,178 @@
1
+ """What omarchy-dms does, as an API: sync, install, update, remove, installed, hook.
2
+
3
+ No printing and no exiting: progress goes to `log`, problems are exceptions
4
+ (DmsError, StoreError, ThemeError), results are returned.
5
+ """
6
+
7
+ import re
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from collections.abc import Callable
12
+ from contextlib import contextmanager
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from . import theme
17
+ from .dms import Dms
18
+ from .store import Store, StoreError, resolve_repo
19
+
20
+ STORE_URL = "https://omarchy.org/themes/"
21
+ NOT_COMMUNITY = {"basecamp", "omacom"}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Row:
26
+ slug: str
27
+ profile: bool
28
+ origin: str # "built-in", the repo URL, or "" for a community theme with no .source
29
+
30
+
31
+ def store_repos():
32
+ """Repos listed on omarchy.org/themes, as owner/repo."""
33
+ req = urllib.request.Request(STORE_URL, headers={"User-Agent": "omarchy-dms"})
34
+ try:
35
+ with urllib.request.urlopen(req, timeout=30) as r: # noqa: S310 (fixed https URL)
36
+ html = r.read().decode("utf-8", "replace")
37
+ except (urllib.error.URLError, TimeoutError) as e:
38
+ raise StoreError(f"{STORE_URL}: {e}") from e
39
+ repos = set(re.findall(r"https://github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)", html))
40
+ return sorted(r for r in repos if r.split("/")[0] not in NOT_COMMUNITY)
41
+
42
+
43
+ class Omarchy:
44
+ def __init__(
45
+ self,
46
+ dms: Dms,
47
+ store: Store,
48
+ builtin_dir: Path | None = None,
49
+ hook_command: str = "omarchy-dms hook",
50
+ log: Callable[[str], None] = print,
51
+ settle: float = 1.0,
52
+ sleep=time.sleep,
53
+ ):
54
+ self.dms = dms
55
+ self.store = store
56
+ self.builtin_dir = Path(builtin_dir) if builtin_dir else None
57
+ self.hook_command = hook_command
58
+ self.log = log
59
+ self.settle = settle
60
+ self._sleep = sleep
61
+
62
+ # -- internals
63
+ @contextmanager
64
+ def _session(self):
65
+ """Autosave off, the wallpaper hook registered, and a way back (Original)."""
66
+ with self.dms.autosave_paused():
67
+ self.dms.set_option("applyCommand", self.hook_command)
68
+ if not self.dms.find_profile("original", "Original"):
69
+ self.log("create: Original")
70
+ self.dms.create_profile("Original")
71
+ yield
72
+
73
+ def _make(self, slug, mode, force):
74
+ name = theme.profile_name(slug)
75
+ existing = self.dms.find_profile(slug, name)
76
+ if existing:
77
+ if not force:
78
+ self.log(f"skip: {name}")
79
+ return
80
+ self.dms.remove_profile(existing["slug"])
81
+ if not self.store.theme_json(slug).is_file():
82
+ raise StoreError(f"{slug}: theme folder missing (deploy the Home Manager config first)")
83
+ self.dms.custom_theme(self.store.theme_json(slug), f"omarchy-{slug}")
84
+ self.dms.set_mode(mode)
85
+ images = self.store.images(slug)
86
+ if images:
87
+ self.dms.set_wallpaper(images[0])
88
+ self._sleep(self.settle)
89
+ self.log(f"create: {name}")
90
+ self.dms.create_profile(name)
91
+
92
+ # -- operations
93
+ def installed(self):
94
+ names = {p.get("slug") for p in self.dms.profiles()} | {p.get("name") for p in self.dms.profiles()}
95
+ return [
96
+ Row(
97
+ t.slug,
98
+ t.slug in names or theme.profile_name(t.slug) in names,
99
+ "built-in" if t.builtin else (t.source or ""),
100
+ )
101
+ for t in self.store.themes()
102
+ ]
103
+
104
+ def sync(self, names=(), force=False):
105
+ """Profiles for Omarchy's own themes (all, or the ones named)."""
106
+ if not self.builtin_dir or not self.builtin_dir.is_dir():
107
+ raise StoreError("Omarchy's own themes are not available (OMARCHY_THEMES is not set)")
108
+ available = {p.parent.name: p for p in sorted(self.builtin_dir.glob("*/colors.toml"))}
109
+ unknown = [n for n in names if n not in available]
110
+ if unknown:
111
+ raise StoreError(f"not an Omarchy theme: {', '.join(unknown)}")
112
+ with self._session():
113
+ for slug, colors in available.items():
114
+ if not names or slug in names:
115
+ self._make(slug, theme.mode_of(theme.load_palette(colors)), force)
116
+
117
+ def install(self, arg):
118
+ """Install a community theme (owner/repo or git URL); returns its slug."""
119
+ url, slug = resolve_repo(arg)
120
+ mode = self.store.fetch(url, slug)
121
+ with self._session():
122
+ self._make(slug, mode, force=True) # reinstalling refreshes the profile
123
+ return slug
124
+
125
+ def update(self, names=()):
126
+ """Refetch installed community themes; returns the slugs that failed.
127
+
128
+ Profiles are left alone: they point at theme.json by path, so the colors
129
+ follow; `install` again to also refresh a profile's own wallpaper copy.
130
+ """
131
+ by_slug = {t.slug: t for t in self.store.themes()}
132
+ for n in names:
133
+ if n not in by_slug or not by_slug[n].source:
134
+ self.log(f"not updatable (no .source): {n}")
135
+ targets = [t for t in by_slug.values() if t.source and (not names or t.slug in names)]
136
+ if not targets and not names:
137
+ self.log("no installed community themes (reinstall them once with: omarchy-dms install <repo>)")
138
+ failed = []
139
+ for t in targets:
140
+ self.log(f"update: {t.slug} ({t.source})")
141
+ try:
142
+ self.store.fetch(t.source, t.slug)
143
+ except (StoreError, theme.ThemeError) as e:
144
+ self.log(f"failed: {t.slug}: {e}")
145
+ failed.append(t.slug)
146
+ return failed
147
+
148
+ def remove(self, names):
149
+ """Delete themes: files, wallpapers and profile. Built-ins only lose the profile."""
150
+ known = set(self.store.slugs())
151
+ unknown = [n for n in names if n not in known]
152
+ if unknown:
153
+ raise StoreError(f"unknown theme: {', '.join(unknown)}")
154
+ with self.dms.autosave_paused():
155
+ active = self.dms.active()
156
+ original = self.dms.find_profile("original", "Original")
157
+ for slug in names:
158
+ prof = self.dms.find_profile(slug, theme.profile_name(slug))
159
+ if prof:
160
+ if active == prof["slug"] and original and original["slug"] != active:
161
+ self.dms.apply_profile(original["slug"]) # leave it before deleting it
162
+ active = original["slug"]
163
+ self.dms.remove_profile(prof["slug"])
164
+ if self.store.remove(slug):
165
+ self.log(f"removed: {slug}")
166
+ else:
167
+ self.log(f"removed profile: {slug} (built-in files are managed by Nix; sync recreates it)")
168
+
169
+ def hook(self, slug):
170
+ """dmsProfiles apply command: point the wallpaper back at the whole theme folder.
171
+
172
+ A profile carries one wallpaper copy (plus its thumbnail), which is all
173
+ the carousel would list. dmsProfiles sets its own wallpaper *after* this
174
+ command, so the change lands a moment later.
175
+ """
176
+ images = self.store.images(slug)
177
+ if images:
178
+ self.dms.set_wallpaper_later(images[0])
omarchy_dms/store.py ADDED
@@ -0,0 +1,131 @@
1
+ """Theme folders and wallpapers on disk, and fetching community themes.
2
+
3
+ Built-in themes (Omarchy's own, deployed by Nix) are symlinks into the store and
4
+ read-only; community ones are real folders this module owns.
5
+ """
6
+
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from . import theme
15
+
16
+ IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
17
+ MIN_IMAGE_BYTES = 2048 # drops the ~700-byte placeholders
18
+ SLUG_RE = re.compile(r"[a-z0-9_][a-z0-9._+-]*")
19
+ SHORTHAND_RE = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+")
20
+
21
+
22
+ class StoreError(RuntimeError):
23
+ pass
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Theme:
28
+ slug: str
29
+ builtin: bool
30
+ source: str | None
31
+
32
+
33
+ def resolve_repo(arg):
34
+ """owner/repo or a git URL -> (url, slug). Same naming as omarchy-theme-install."""
35
+ arg = arg.strip()
36
+ if arg.startswith(("https://", "ssh://", "git@")):
37
+ url = arg
38
+ elif SHORTHAND_RE.fullmatch(arg):
39
+ url = f"https://github.com/{arg}"
40
+ else:
41
+ raise StoreError("expected owner/repo or a git URL")
42
+ name = arg.rstrip("/").removesuffix(".git").rsplit("/", 1)[-1].rsplit(":", 1)[-1].lower()
43
+ slug = name.removeprefix("omarchy-").removesuffix("-theme")
44
+ if not SLUG_RE.fullmatch(slug):
45
+ raise StoreError(f"unusable theme name: {slug!r}")
46
+ return url, slug
47
+
48
+
49
+ class Store:
50
+ def __init__(self, themes_dir, wall_dir):
51
+ self.themes_dir = Path(themes_dir)
52
+ self.wall_dir = Path(wall_dir)
53
+
54
+ def theme_dir(self, slug):
55
+ return self.themes_dir / f"omarchy-{slug}"
56
+
57
+ def theme_json(self, slug):
58
+ return self.theme_dir(slug) / "theme.json"
59
+
60
+ def wallpapers(self, slug):
61
+ return self.wall_dir / slug
62
+
63
+ def slugs(self):
64
+ # .source counts too: a community theme whose theme.json went missing can still be updated.
65
+ return sorted(
66
+ d.name.removeprefix("omarchy-")
67
+ for d in self.themes_dir.glob("omarchy-*")
68
+ if (d / "theme.json").is_file() or (d / ".source").is_file()
69
+ )
70
+
71
+ def is_builtin(self, slug):
72
+ return self.theme_json(slug).is_symlink() or self.wallpapers(slug).is_symlink()
73
+
74
+ def source(self, slug):
75
+ f = self.theme_dir(slug) / ".source"
76
+ return f.read_text().strip() if f.is_file() else None
77
+
78
+ def themes(self):
79
+ return [Theme(s, self.is_builtin(s), self.source(s)) for s in self.slugs()]
80
+
81
+ def images(self, slug):
82
+ d = self.wallpapers(slug)
83
+ return sorted(p for p in d.iterdir() if p.is_file()) if d.is_dir() else []
84
+
85
+ def fetch(self, url, slug):
86
+ """Clone URL and (re)write the theme folder and wallpapers; return the mode."""
87
+ if not SLUG_RE.fullmatch(slug):
88
+ raise StoreError(f"unusable theme name: {slug!r}")
89
+ if self.is_builtin(slug):
90
+ raise StoreError(f"{slug} is one of Omarchy's built-in themes")
91
+ with tempfile.TemporaryDirectory() as tmp:
92
+ repo = Path(tmp) / "repo"
93
+ r = subprocess.run(
94
+ ["git", "clone", "--quiet", "--depth", "1", "--", url, str(repo)],
95
+ capture_output=True,
96
+ text=True,
97
+ check=False,
98
+ )
99
+ if r.returncode:
100
+ raise StoreError(f"git clone {url}: {r.stderr.strip()}")
101
+ colors = repo / "colors.toml"
102
+ if not colors.is_file():
103
+ raise StoreError(f"{url} has no colors.toml (alacritty-only themes are not supported)")
104
+ mode = theme.render(colors, slug, self.theme_dir(slug))
105
+ walls = self.wallpapers(slug)
106
+ walls.mkdir(parents=True, exist_ok=True)
107
+ backgrounds = repo / "backgrounds"
108
+ for img in sorted(backgrounds.iterdir()) if backgrounds.is_dir() else []:
109
+ if (
110
+ img.is_file()
111
+ and not img.is_symlink()
112
+ and img.suffix.lower() in IMAGE_SUFFIXES
113
+ and img.stat().st_size > MIN_IMAGE_BYTES
114
+ ):
115
+ shutil.copyfile(img, walls / img.name)
116
+ # Remembered so `update` can fetch it again.
117
+ (self.theme_dir(slug) / ".source").write_text(url + "\n")
118
+ return mode
119
+
120
+ def remove(self, slug):
121
+ """Delete a community theme and its wallpapers; False for a built-in one."""
122
+ if slug not in self.slugs():
123
+ raise StoreError(f"unknown theme: {slug}")
124
+ if self.is_builtin(slug):
125
+ return False
126
+ try:
127
+ shutil.rmtree(self.theme_dir(slug))
128
+ shutil.rmtree(self.wallpapers(slug), ignore_errors=True)
129
+ except OSError as e:
130
+ raise StoreError(f"cannot remove {slug}: {e}") from e
131
+ return True
omarchy_dms/theme.py ADDED
@@ -0,0 +1,156 @@
1
+ """Omarchy colors.toml -> DMS custom theme folder (theme.json + Browse previews).
2
+
3
+ Pure: no DMS, no network. Omarchy has no Material roles, so the mapping is an
4
+ approximation. Community themes come in two shapes: the derived palette
5
+ (accent, selection, red, ...) and a plain ANSI one (color0..color15). Whatever is
6
+ missing is derived from background/foreground and the ANSI colors.
7
+ """
8
+
9
+ import json
10
+ import pathlib
11
+ import string
12
+ import tomllib
13
+
14
+ REQUIRED = ("background", "foreground", "accent")
15
+ FONT = 'font-family="system-ui, sans-serif"'
16
+
17
+
18
+ class ThemeError(ValueError):
19
+ pass
20
+
21
+
22
+ def _rgb(h):
23
+ h = h.lstrip("#")
24
+ if len(h) in (3, 4):
25
+ h = "".join(ch * 2 for ch in h)
26
+ try:
27
+ return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4))
28
+ except ValueError as e:
29
+ raise ThemeError(f"bad color {h!r}") from e
30
+
31
+
32
+ def _hexed(t):
33
+ r, g, b = (round(v) for v in t)
34
+ return f"#{r:02x}{g:02x}{b:02x}"
35
+
36
+
37
+ def _mix(a, b, t):
38
+ return _hexed(tuple(x * (1 - t) + y * t for x, y in zip(_rgb(a), _rgb(b), strict=True)))
39
+
40
+
41
+ def _luminance(h):
42
+ r, g, b = (v / 255 for v in _rgb(h))
43
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
44
+
45
+
46
+ def profile_name(slug):
47
+ return string.capwords(slug.replace("-", " "))
48
+
49
+
50
+ def load_palette(path):
51
+ try:
52
+ palette = tomllib.loads(pathlib.Path(path).read_text())
53
+ except (OSError, tomllib.TOMLDecodeError) as e:
54
+ raise ThemeError(f"{path}: {e}") from e
55
+ missing = [k for k in REQUIRED if k not in palette]
56
+ if missing:
57
+ raise ThemeError(f"{path}: missing {', '.join(missing)}")
58
+ return palette
59
+
60
+
61
+ def mode_of(palette):
62
+ return palette.get("mode") or ("light" if _luminance(palette["background"]) > 0.5 else "dark")
63
+
64
+
65
+ def convert(palette, slug):
66
+ bg, fg, accent = palette["background"], palette["foreground"], palette["accent"]
67
+ light = mode_of(palette) == "light"
68
+ raise_to = "#000000" if light else "#ffffff"
69
+
70
+ def pick(*keys, default):
71
+ return next((palette[k] for k in keys if k in palette), default)
72
+
73
+ dark_bg = pick("dark_background", default=_mix(bg, "#000000", 0.05 if light else 0.15))
74
+ darker_bg = pick("darker_background", default=_mix(bg, "#000000", 0.1 if light else 0.3))
75
+ lighter_bg = pick("lighter_background", default=_mix(bg, raise_to, 0.08))
76
+ # selection_background in the ANSI shape is often a loud highlight, so not used.
77
+ selection = pick("selection", default=_mix(bg, raise_to, 0.16))
78
+
79
+ colors = {
80
+ "primary": accent,
81
+ "primaryText": bg,
82
+ "primaryContainer": selection,
83
+ "secondary": pick("magenta", "color5", default=accent),
84
+ "surface": bg,
85
+ "surfaceText": fg,
86
+ "surfaceVariant": dark_bg if light else lighter_bg,
87
+ "surfaceVariantText": fg,
88
+ "surfaceTint": accent,
89
+ "background": bg if light else dark_bg,
90
+ "backgroundText": pick("bright_foreground", default=fg),
91
+ "outline": pick("muted", "color8", default=_mix(fg, bg, 0.5)),
92
+ "surfaceContainerLowest": bg if light else darker_bg,
93
+ "surfaceContainerLow": bg if light else dark_bg,
94
+ "surfaceContainer": dark_bg if light else lighter_bg,
95
+ "surfaceContainerHigh": darker_bg if light else selection,
96
+ "error": pick("red", "color1", default=accent),
97
+ "warning": pick("yellow", "color3", default=accent),
98
+ "info": pick("blue", "color4", default=accent),
99
+ }
100
+ return {
101
+ "id": f"omarchy-{slug}",
102
+ "name": f"Omarchy {profile_name(slug)}",
103
+ "version": "1.0.0",
104
+ "author": "Omarchy",
105
+ "description": f"Omarchy {slug} palette",
106
+ "dark": colors,
107
+ "light": colors,
108
+ }
109
+
110
+
111
+ def preview(theme, mode):
112
+ c = theme["dark"]
113
+ title = f"{theme['name']} ({mode})"
114
+ return f"""<svg xmlns="http://www.w3.org/2000/svg" width="280" height="240" viewBox="0 0 280 240">
115
+ <rect width="280" height="240" fill="{c["background"]}"/>
116
+ <rect width="280" height="26" fill="{c["surface"]}"/>
117
+ <rect x="12" y="9" width="20" height="8" rx="4" fill="{c["primary"]}"/>
118
+ <circle cx="40" cy="13" r="3" fill="{c["surfaceVariantText"]}"/>
119
+ <circle cx="50" cy="13" r="3" fill="{c["surfaceVariantText"]}"/>
120
+ <text x="140" y="17" {FONT} font-size="10" font-weight="600" text-anchor="middle" fill="{c["primary"]}">9:41</text>
121
+ <circle cx="238" cy="13" r="4" fill="{c["info"]}"/>
122
+ <circle cx="252" cy="13" r="4" fill="{c["warning"]}"/>
123
+ <circle cx="266" cy="13" r="4" fill="{c["error"]}"/>
124
+ <rect x="14" y="38" width="252" height="190" rx="12" fill="{c["surface"]}" stroke="{c["outline"]}" stroke-opacity="0.5"/>
125
+ <text x="28" y="61" {FONT} font-size="12.5" font-weight="600" fill="{c["surfaceText"]}">{title}</text>
126
+ <text x="28" y="77" {FONT} font-size="9.5" fill="{c["surfaceVariantText"]}">Secondary text</text>
127
+ <rect x="26" y="87" width="228" height="50" rx="8" fill="{c["surfaceContainerHigh"]}"/>
128
+ <text x="38" y="105" {FONT} font-size="10" font-weight="500" fill="{c["surfaceText"]}">Nested card</text>
129
+ <text x="38" y="120" {FONT} font-size="9" fill="{c["surfaceVariantText"]}">Body text on an elevated surface</text>
130
+ <rect x="26" y="145" width="228" height="22" rx="6" fill="{c["surface"]}" stroke="{c["outline"]}" stroke-opacity="0.4"/>
131
+ <text x="38" y="160" {FONT} font-size="9" fill="{c["surfaceVariantText"]}">Search</text>
132
+ <rect x="26" y="175" width="78" height="22" rx="11" fill="{c["primary"]}"/>
133
+ <text x="65" y="190" {FONT} font-size="9.5" font-weight="600" text-anchor="middle" fill="{c["primaryText"]}">Button</text>
134
+ <rect x="112" y="175" width="70" height="22" rx="11" fill="{c["primary"]}" fill-opacity="0.15"/>
135
+ <text x="147" y="190" {FONT} font-size="9.5" font-weight="500" text-anchor="middle" fill="{c["primary"]}">Accent</text>
136
+ <rect x="190" y="175" width="64" height="22" rx="11" fill="{c["surfaceContainerHigh"]}"/>
137
+ <text x="222" y="190" {FONT} font-size="9.5" text-anchor="middle" fill="{c["surfaceText"]}">Chip</text>
138
+ <circle cx="34" cy="212" r="7" fill="{c["primary"]}"/>
139
+ <circle cx="54" cy="212" r="7" fill="{c["secondary"]}"/>
140
+ <circle cx="74" cy="212" r="7" fill="{c["error"]}"/>
141
+ <circle cx="94" cy="212" r="7" fill="{c["warning"]}"/>
142
+ <circle cx="114" cy="212" r="7" fill="{c["info"]}"/>
143
+ </svg>
144
+ """
145
+
146
+
147
+ def render(colors_toml, slug, outdir):
148
+ """Write theme.json and the two Browse previews into OUTDIR; return the mode."""
149
+ palette = load_palette(colors_toml)
150
+ theme = convert(palette, slug)
151
+ out = pathlib.Path(outdir)
152
+ out.mkdir(parents=True, exist_ok=True)
153
+ (out / "theme.json").write_text(json.dumps(theme, indent=2) + "\n")
154
+ for mode in ("dark", "light"):
155
+ (out / f"preview-{mode}.svg").write_text(preview(theme, mode))
156
+ return mode_of(palette)
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: omarchy-dms
3
+ Version: 0.1.0
4
+ Summary: Omarchy themes as Dank Material Shell (dmsProfiles) looks
5
+ Author: Mikel Villota
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/Villoh/omarchy-dms
8
+ Project-URL: Issues, https://github.com/Villoh/omarchy-dms/issues
9
+ Keywords: omarchy,dms,dankmaterialshell,theme,dmsprofiles
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Desktop Environment
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # omarchy-dms
22
+
23
+ Use [Omarchy](https://omarchy.org) themes in [Dank Material Shell](https://danklinux.com)
24
+ as [dmsProfiles](https://github.com/satoshoe-dev/dms-profiles) looks: colors, wallpapers
25
+ and light/dark mode, switchable from the profile picker.
26
+
27
+ It converts an Omarchy `colors.toml` into a DMS custom theme (with the Browse previews),
28
+ copies the theme's wallpapers, and creates one profile per theme through `dms ipc`.
29
+ Themes from the [omarchy.org/themes](https://omarchy.org/themes/) store install with one command.
30
+
31
+ ## Requirements
32
+
33
+ DMS with the `dmsProfiles` plugin enabled, Python 3.11+, `git`, and optionally `fzf`
34
+ (pickers). No other dependencies.
35
+
36
+ ## Install
37
+
38
+ ```sh
39
+ pipx install omarchy-dms
40
+ # or, from the repository
41
+ pipx install git+https://github.com/Villoh/omarchy-dms
42
+ ```
43
+
44
+ On Nix: `nix run github:Villoh/omarchy-dms`.
45
+
46
+ ## Usage
47
+
48
+ ```sh
49
+ omarchy-dms install # pick from the omarchy.org/themes store (fzf)
50
+ omarchy-dms install owner/repo # or a repo / git URL
51
+ omarchy-dms installed # every theme, its origin, and whether it has a profile
52
+ omarchy-dms update [theme...] # refetch installed themes (all, or the ones named)
53
+ omarchy-dms remove [-y] [theme...] # theme, wallpapers and profile (no names: fzf, Tab marks)
54
+ omarchy-dms list # repos listed on the store
55
+ omarchy-dms sync [--force] [theme...] # profiles for Omarchy's own themes (see below)
56
+ ```
57
+
58
+ Themes land in `~/.config/DankMaterialShell/themes/omarchy-<name>/` and wallpapers in
59
+ `~/Pictures/Wallpapers/omarchy/<name>/`. Only themes with a `colors.toml` work; alacritty-only
60
+ themes are rejected.
61
+
62
+ **Own themes.** `sync` needs Omarchy's own `themes/` directory, given by `OMARCHY_THEMES`
63
+ (for example a checkout of `basecamp/omarchy`), with the theme folders already in place
64
+ for DMS: `omarchy-dms render <colors.toml> <name> <outdir>` writes one.
65
+
66
+ **Wallpaper carousel.** dmsProfiles stores one wallpaper copy per profile. The CLI registers
67
+ itself as the profile *apply command* so that, after a switch, the wallpaper points back at the
68
+ theme's folder and a carousel lists all of its images.
69
+
70
+ ## As a library
71
+
72
+ `omarchy_dms.Omarchy(dms, store, ...)` is the API behind the CLI: it never prints or exits,
73
+ progress goes to a `log` callback and failures are exceptions (`DmsError`, `StoreError`,
74
+ `ThemeError`). Another frontend can import it instead of running the CLI.
75
+
76
+ ```python
77
+ from omarchy_dms import Dms, Omarchy, Store
78
+
79
+ omarchy = Omarchy(Dms(), Store(themes_dir, wallpapers_dir), log=print)
80
+ omarchy.install("owner/omarchy-example-theme")
81
+ ```
82
+
83
+ ## Development
84
+
85
+ ```sh
86
+ uv sync
87
+ uv run python -m unittest discover -s tests
88
+ uv run ruff check . && uv run ruff format --check .
89
+ ```
90
+
91
+ ## Releasing
92
+
93
+ Bump `version` in `pyproject.toml` (the Nix package reads it from there), commit, then tag it:
94
+
95
+ ```sh
96
+ git tag v0.1.0 && git push origin v0.1.0
97
+ ```
98
+
99
+ `.github/workflows/release.yml` does the rest: it checks the tag against the version, runs CI,
100
+ builds and smoke-tests the package, publishes it to PyPI with trusted publishing, and creates the
101
+ GitHub release. Its notes are `.github/release-notes/<tag>.md` if you wrote one, otherwise
102
+ the ones GitHub generates from merged pull requests.
103
+
104
+ ## License
105
+
106
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ omarchy_dms/__init__.py,sha256=tI51D_-QAhZ5vcpgPABDotIrJ6Qy_r-XN-OaWhSY1f0,582
2
+ omarchy_dms/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ omarchy_dms/cli.py,sha256=itSD_2Dy4tXR88daRkSDHnFIe7FeEhHTr0tp3N6qlaA,3962
4
+ omarchy_dms/dms.py,sha256=VO5bUU1jmVW0Lp1_zb0Ge7114Omb39LKTJ8RQDcQQdI,4382
5
+ omarchy_dms/ops.py,sha256=OPrgmO3qE7gZNS6eKuWxhVYipYX8GhwPFgds_pWZndI,7214
6
+ omarchy_dms/store.py,sha256=0fJb4gbjfpwUPTFtJ6t99oxgA5L3Jm5LQsxaaeFbEGw,4741
7
+ omarchy_dms/theme.py,sha256=wqJ-GENooVwpOKM0s2hN3THT-p_UUpwSOwv9h2yT1WY,6702
8
+ omarchy_dms-0.1.0.dist-info/licenses/LICENSE,sha256=hkEhFFC3gg9l89-tV41jm_lOVXOwWNiVcHnjSJx8Zlc,1070
9
+ omarchy_dms-0.1.0.dist-info/METADATA,sha256=OOWDPWmmvm2f61Q_v3MNimpwdIF-0WF8y-BnQO_YvMQ,3998
10
+ omarchy_dms-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ omarchy_dms-0.1.0.dist-info/entry_points.txt,sha256=C8jyEhOYeZt4Z6dytnFt7ur6zQKk87ydDTI62Bx0UJo,53
12
+ omarchy_dms-0.1.0.dist-info/top_level.txt,sha256=vegHpJfKNIIsuMlyNO62hfsFjNoY3C_4Kc1VKhr7wew,12
13
+ omarchy_dms-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ omarchy-dms = omarchy_dms.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikel Villota
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ omarchy_dms