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.
- omarchy_dms/__init__.py +24 -0
- omarchy_dms/__main__.py +5 -0
- omarchy_dms/cli.py +92 -0
- omarchy_dms/dms.py +132 -0
- omarchy_dms/ops.py +178 -0
- omarchy_dms/store.py +131 -0
- omarchy_dms/theme.py +156 -0
- omarchy_dms-0.1.0.dist-info/METADATA +106 -0
- omarchy_dms-0.1.0.dist-info/RECORD +13 -0
- omarchy_dms-0.1.0.dist-info/WHEEL +5 -0
- omarchy_dms-0.1.0.dist-info/entry_points.txt +2 -0
- omarchy_dms-0.1.0.dist-info/licenses/LICENSE +21 -0
- omarchy_dms-0.1.0.dist-info/top_level.txt +1 -0
omarchy_dms/__init__.py
ADDED
|
@@ -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
|
+
]
|
omarchy_dms/__main__.py
ADDED
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,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
|