Compare commits

...

3 Commits

Author SHA1 Message Date
nullishamy
b508c0ff2d feat: xfwm4 asset generation (#206) 2024-05-20 23:09:22 +01:00
nullishamy
44be6bb595 ci: build in PRs (#202)
* ci: build in PRs

* ci: improve naming

* ci: upload individual artifacts

* ci: try running when PRs are pushed to

* ci: name

* ci: try individual again

* ci: friendship ended with gha; gitlab ci/cd is my new friend

* feat: introduce --from-artifact opt in install script
2024-05-20 16:34:12 +01:00
nullishamy
6eeda71fb7 fix: disallow 'all' in install.py accent options (#205) 2024-05-20 13:50:02 +01:00
24 changed files with 1055 additions and 18205 deletions

35
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: "Generate test artifacts"
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Install colloid specific dependencies
run: sudo apt update && sudo apt install -y sassc inkscape optipng
- name: Generate themes
run: |
python patches/xfwm4/generate_assets.py
python ./build.py mocha --all-accents --zip -d $PWD/releases &&
python ./build.py macchiato --all-accents --zip -d $PWD/releases &&
python ./build.py frappe --all-accents --zip -d $PWD/releases &&
python ./build.py latte --all-accents --zip -d $PWD/releases
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: '${{ github.sha }}-artifacts'
path: ./releases/*.zip

View File

@@ -1,4 +1,4 @@
name: "Release" name: "Mainline release"
on: on:
push: push:

View File

@@ -17,4 +17,24 @@ Once the build script patches the submodule, it will write a file into
The palette patches are generated through `whiskers`, The palette patches are generated through `whiskers`,
so if you're changing them, they will need regenerated. Simply run `whiskers palette.tera` to rebuild them. so if you're changing them, they will need regenerated. Simply run `whiskers palette.tera` to rebuild them.
The process for building the theme is [documented in the README](./README.md#building). The process for building the theme is [documented in the README](./README.md#building).
### Running test builds
We support building and publishing test builds from PRs. When you open PRs, the CI will automatically build with your changes and push an artifact
which bundles all of the produced themes.
You can then download the artifacts as a zip (result should look similar to 7bff2448a81e36bf3b0e03bfbd649bebe6973ec7-artifacts.zip) and
pass the path into `install.py` under the `--from-artifact` option:
```bash
python3 install.py mocha blue --dest ./build --from-artifact ~/downloads/7bff2448a81e36bf3b0e03bfbd649bebe6973ec7-artifacts.zip
```
This will take the target flavor / accent out of the zip, and install it using the regular install process.
It is advised to pass a `--dest` when running in this mode, because the released zips follow the exact same naming scheme as regular builds.
This wil cause conflicts when you install, if you already had that theme installed. Passing a different destination allows you to move the
extracted folders to `~/.local/share/themes` yourself, adding a suffix as appropriate to avoid conflicts.
> [!WARNING]
> If you pass `--link` to the install script when working from a PR, it will forcibly overwrite your `~/.config/gtk-4.0/` symlinks.
> You will have to reinstall / relink to revert this.

View File

@@ -393,17 +393,16 @@ def make_assets(ctx: BuildContext):
f"{output_dir}/metacity-1/thumbnail.png", f"{output_dir}/metacity-1/thumbnail.png",
) )
# TODO: Make our own assets for this and patch them in with the patch system, then code it to be xfwm4_assets = f"{THIS_DIR}/patches/xfwm4/generated/assets-catppuccin-{ctx.flavor.identifier}"
# {src_dir}/assets/xfwm4/assets{light_suffix}-Catppuccin/ for file in glob.glob(xfwm4_assets + '/*'):
# where assets-Light-Catppuccin will have latte
# nad assets-Catppuccin will have mocha or something
for file in glob.glob(f"{SRC_DIR}/assets/xfwm4/assets{ctx.apply_suffix(IS_LIGHT)}/*.png"):
shutil.copy(file, f"{output_dir}/xfwm4") shutil.copy(file, f"{output_dir}/xfwm4")
for file in glob.glob(f"{SRC_DIR}/assets/xfwm4/assets{ctx.apply_suffix(IS_LIGHT)}-hdpi/*.png"): xfwm4_assets = xfwm4_assets + "-hdpi/*"
for file in glob.glob(xfwm4_assets):
shutil.copy(file, f"{output_dir}-hdpi/xfwm4") shutil.copy(file, f"{output_dir}-hdpi/xfwm4")
for file in glob.glob(f"{SRC_DIR}/assets/xfwm4/assets{ctx.apply_suffix(IS_LIGHT)}-xhdpi/*.png"): xfwm4_assets = xfwm4_assets + "-xhdpi/*"
for file in glob.glob(xfwm4_assets):
shutil.copy(file, f"{output_dir}-xhdpi/xfwm4") shutil.copy(file, f"{output_dir}-xhdpi/xfwm4")
@@ -491,7 +490,6 @@ def apply_colloid_patches():
logger.info("Patching finished.") logger.info("Patching finished.")
def parse_args(): def parse_args():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument( parser.add_argument(

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os, zipfile, argparse, logging, io import os, zipfile, argparse, logging, io
from typing import Optional
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
from urllib.request import urlopen, Request from urllib.request import urlopen, Request
from urllib.parse import urlparse
logger = logging.getLogger("catppuccin-gtk") logger = logging.getLogger("catppuccin-gtk")
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
@@ -20,6 +22,17 @@ class InstallContext:
dest: Path dest: Path
link: bool link: bool
def build_info(self, include_url=True) -> str:
url = build_release_url(self)
info = f"""Installation info:
flavor: {self.flavor}
accent: {self.accent}
dest: {self.dest.absolute()}
link: {self.link}"""
if include_url:
info += f"\nremote_url: {url}"
return info
def parse_args(): def parse_args():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@@ -49,11 +62,17 @@ def parse_args():
"sapphire", "sapphire",
"blue", "blue",
"lavender", "lavender",
"all",
], ],
help="Accent of the theme.", help="Accent of the theme.",
) )
parser.add_argument(
"--from-artifact",
type=Path,
dest="from_artifact",
help="Install from an artifact instead of a mainline release, pass the artifact path (e.g 7bff2448a81e36bf3b0e03bfbd649bebe6973ec7-artifacts.zip)",
)
parser.add_argument( parser.add_argument(
"--dest", "--dest",
"-d", "-d",
@@ -81,21 +100,12 @@ def build_release_url(ctx: InstallContext) -> str:
return f"{repo_root}/{release}/{zip_name}" return f"{repo_root}/{release}/{zip_name}"
def install(ctx: InstallContext): def fetch_zip(url: str) -> Optional[zipfile.ZipFile]:
url = build_release_url(ctx) req = Request(url)
build_info = f"""Installation info:
flavor: {ctx.flavor}
accent: {ctx.accent}
dest: {ctx.dest.absolute()}
link: {ctx.link}
remote_url: {url}"""
logger.info(build_info)
httprequest = Request(url)
zip_file = None zip_file = None
logger.info("Starting download...") logger.info("Starting download...")
with urlopen(httprequest) as response: with urlopen(req) as response:
logger.info(f"Response status: {response.status}") logger.info(f"Response status: {response.status}")
zip_file = zipfile.ZipFile(io.BytesIO(response.read())) zip_file = zipfile.ZipFile(io.BytesIO(response.read()))
logger.info("Download finished, zip is valid") logger.info("Download finished, zip is valid")
@@ -104,28 +114,81 @@ def install(ctx: InstallContext):
first_bad_file = zip_file.testzip() first_bad_file = zip_file.testzip()
if first_bad_file is not None: if first_bad_file is not None:
logger.error(f'Zip appears to be corrupt, first bad file is "{first_bad_file}"') logger.error(f'Zip appears to be corrupt, first bad file is "{first_bad_file}"')
return return None
logger.info("Download verified") logger.info("Download verified")
return zip_file
def add_libadwaita_links(ctx: InstallContext, rewrite: True):
dir_name = (
ctx.dest / f"catppuccin-{ctx.flavor}-{ctx.accent}-standard+default" / "gtk-4.0"
).absolute()
gtk4_dir = (Path(os.path.expanduser("~")) / ".config" / "gtk-4.0").absolute()
os.makedirs(gtk4_dir, exist_ok=True)
logger.info("Adding symlinks for libadwaita")
logger.info(f"Root: {dir_name}")
logger.info(f"Target: {gtk4_dir}")
try:
if rewrite:
os.remove(dir_name / "assets", gtk4_dir / "assets")
os.remove(dir_name / "gtk.css", gtk4_dir / "gtk.css")
os.remove(dir_name / "gtk-dark.css", gtk4_dir / "gtk-dark.css")
except FileNotFoundError:
logger.debug("Ignoring FileNotFound in symlink rewrite")
os.symlink(dir_name / "assets", gtk4_dir / "assets")
os.symlink(dir_name / "gtk.css", gtk4_dir / "gtk.css")
os.symlink(dir_name / "gtk-dark.css", gtk4_dir / "gtk-dark.css")
def install(ctx: InstallContext):
url = build_release_url(ctx)
logger.info(ctx.build_info())
zip_file = fetch_zip(url)
if zip_file is None:
return
logger.info("Extracting...") logger.info("Extracting...")
zip_file.extractall(ctx.dest) zip_file.extractall(ctx.dest)
logger.info("Extraction complete") logger.info("Extraction complete")
if ctx.link: if ctx.link:
dir_name = (ctx.dest / f"catppuccin-{ctx.flavor}-{ctx.accent}-standard+default" / 'gtk-4.0').absolute() add_libadwaita_links(ctx)
gtk4_dir = (Path(os.path.expanduser('~')) / '.config' / 'gtk-4.0').absolute()
os.makedirs(gtk4_dir, exist_ok=True)
logger.info("Adding symlinks for libadwaita") def install_from_artifact(ctx: InstallContext, artifact_path: Path):
logger.info(f'Root: {dir_name}') # Working from a pull request, special case it
logger.info(f'Target: {gtk4_dir}') logger.info(f"Extracting artifact from '{artifact_path}'")
os.symlink(dir_name / 'assets', gtk4_dir / 'assets') artifacts = zipfile.ZipFile(artifact_path)
os.symlink(dir_name / 'gtk.css', gtk4_dir / 'gtk.css')
os.symlink(dir_name / 'gtk-dark.css', gtk4_dir / 'gtk-dark.css')
logger.info("Verifying artifact...")
first_bad_file = artifacts.testzip()
if first_bad_file is not None:
logger.error(f'Zip appears to be corrupt, first bad file is "{first_bad_file}"')
return None
logger.info("Artifact verified")
logger.info(ctx.build_info(False))
# The zip, inside the artifacts, that we want to pull out
zip_name = f"catppuccin-{ctx.flavor}-{ctx.accent}-standard+default.zip"
logger.info(f"Pulling '{zip_name}' from the artifacts")
info = artifacts.getinfo(zip_name)
logger.info("Extracting the artifact...")
artifact = zipfile.ZipFile(io.BytesIO(artifacts.read(info)))
artifact.extractall(ctx.dest)
logger.info("Extraction complete")
if ctx.link:
logger.info("Adding links (with rewrite)")
add_libadwaita_links(ctx, True)
logger.info("Links added")
def main(): def main():
args = parse_args() args = parse_args()
dest = Path(os.path.expanduser("~")) / ".local" / "share" / "themes" dest = Path(os.path.expanduser("~")) / ".local" / "share" / "themes"
os.makedirs(dest, exist_ok=True) os.makedirs(dest, exist_ok=True)
@@ -136,9 +199,12 @@ def main():
flavor=args.flavor, accent=args.accent, dest=dest, link=args.link flavor=args.flavor, accent=args.accent, dest=dest, link=args.link
) )
install(ctx) if args.from_artifact:
install_from_artifact(ctx, args.from_artifact)
return
logger.info('Theme installation complete!') install(ctx)
logger.info("Theme installation complete!")
try: try:

2
patches/xfwm4/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
generated/
patched/

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,244 @@
from typing import List
from catppuccin import PALETTE
from catppuccin.models import Flavor
import re, os, shutil, subprocess, time
from dataclasses import dataclass
THIS_DIR = os.path.dirname(os.path.realpath(__file__))
INDEX = [
"close-active",
"close-inactive",
"close-prelight",
"close-pressed",
"hide-active",
"hide-inactive",
"hide-prelight",
"hide-pressed",
"maximize-active",
"maximize-inactive",
"maximize-prelight",
"maximize-pressed",
"maximize-toggled-active",
"maximize-toggled-inactive",
"maximize-toggled-prelight",
"maximize-toggled-pressed",
"menu-active",
"menu-inactive",
"menu-prelight",
"menu-pressed",
"shade-active",
"shade-inactive",
"shade-prelight",
"shade-pressed",
"shade-toggled-active",
"shade-toggled-inactive",
"shade-toggled-prelight",
"shade-toggled-pressed",
"stick-active",
"stick-inactive",
"stick-prelight",
"stick-pressed",
"stick-toggled-active",
"stick-toggled-inactive",
"stick-toggled-prelight",
"stick-toggled-pressed",
"title-1-active",
"title-1-inactive",
"title-2-active",
"title-2-inactive",
"title-3-active",
"title-3-inactive",
"title-4-active",
"title-4-inactive",
"title-5-active",
"title-5-inactive",
"top-left-active",
"top-left-inactive",
"top-right-active",
"top-right-inactive",
"left-active",
"left-inactive",
"right-active",
"right-inactive",
"bottom-active",
"bottom-inactive",
"bottom-left-active",
"bottom-left-inactive",
"bottom-right-active",
"bottom-right-inactive",
]
def subst_text(path, _from, to):
with open(path, "r+") as f:
content = f.read()
f.seek(0)
f.truncate()
f.write(re.sub(_from, to, content))
def generate_for_flavor(flavor: Flavor):
# Setup the palette
palette = flavor.colors
close_color=f'#{palette.red.hex}'
max_color=f'#{palette.green.hex}'
min_color=f'#{palette.yellow.hex}'
# We expand the source assets into the 4 flavors, but need to map between
# Their definition of dark and ours. This means that latte will get the -light assets
# and the rest get the -dark assets to work from
color = "-dark"
if not flavor.dark:
color = "-light"
# Make a directory for our patched SVGs to live in before compilation
odir = f"{THIS_DIR}/patched"
os.makedirs(odir, exist_ok=True)
# Copy the base assets into the output
shutil.copy(
f"{THIS_DIR}/assets{color}.svg",
f"{odir}/assets-catppuccin-{flavor.identifier}.svg",
)
shutil.copy(
f"{THIS_DIR}/assets{color}-normal.svg",
f"{odir}/assets-catppuccin-{flavor.identifier}-normal.svg",
)
# Patch all the SVGs
path = f"{odir}/assets-catppuccin-{flavor.identifier}-normal.svg"
subst_text(path, "#fd5f51", close_color)
subst_text(path, "#38c76a", max_color)
subst_text(path, "#fdbe04", min_color)
headerbar = palette.base.hex
headerbar_backdrop = palette.mantle.hex
if flavor.dark:
path = f"{odir}/assets-catppuccin-{flavor.identifier}.svg"
subst_text(path, "#242424", headerbar)
subst_text(path, "#2c2c2c", headerbar_backdrop)
path = f"{odir}/assets-catppuccin-{flavor.identifier}-normal.svg"
subst_text(path, "#242424", headerbar)
subst_text(path, "#2c2c2c", headerbar_backdrop)
else:
path = f"{odir}/assets-catppuccin-{flavor.identifier}.svg"
subst_text(path, "#f2f2f2", headerbar)
subst_text(path, "#fafafa", headerbar_backdrop)
path = f"{odir}/assets-catppuccin-{flavor.identifier}-normal.svg"
subst_text(path, "#f2f2f2", headerbar)
subst_text(path, "#fafafa", headerbar_backdrop)
@dataclass
class WorkerInput:
output_path: str
output_dir: str
input_path: str
dpi: str
ident: str
def call_subprocesses(inp: WorkerInput):
inkscape = "inkscape"
optipng = "optipng"
return [
subprocess.Popen(
[
inkscape,
"--export-id",
inp.ident,
"--export-id-only",
"--export-dpi",
inp.dpi,
"--export-filename",
inp.output_path,
inp.input_path,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
),
subprocess.Popen(
[optipng, "-o7", "--quiet", inp.output_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
),
]
@dataclass
class RenderState:
tasks: List[subprocess.Popen]
input_root: str
output_root: str
screen_to_dpi = {
"-hdpi": "144",
"-xhdpi": "192",
}
def render_for_screen(state: RenderState, flavor: Flavor, screen: str, ident: str):
# NOTE: We do not generate for the -normal variant currently, that would just be
# a stupid amount of compute and time for little benefit
src_file = f"{state.input_root}/assets-catppuccin-{flavor.identifier}.svg"
output_dir = f"{state.output_root}/assets-catppuccin-{flavor.identifier}{screen}"
output_path = f"{output_dir}/{ident}.png"
dpi = screen_to_dpi.get(screen, "96")
os.makedirs(output_dir, exist_ok=True)
if os.path.exists(output_path):
print(f"Skipping '{output_path}', already generated")
else:
new_tasks = call_subprocesses(
WorkerInput(
output_path=output_path,
output_dir=output_dir,
input_path=src_file,
dpi=dpi,
ident=ident,
)
)
state.tasks.extend(new_tasks)
def render_for_flavor(flavor: Flavor, state: RenderState):
print(
f"Starting render tasks for {flavor.identifier}"
)
for ident in INDEX:
render_for_screen(state=state, flavor=flavor, screen="", ident=ident)
render_for_screen(state=state, flavor=flavor, screen="-hdpi", ident=ident)
render_for_screen(state=state, flavor=flavor, screen="-xhdpi", ident=ident)
generate_for_flavor(PALETTE.mocha)
generate_for_flavor(PALETTE.latte)
generate_for_flavor(PALETTE.macchiato)
generate_for_flavor(PALETTE.frappe)
state = RenderState(
tasks=[], input_root=f"{THIS_DIR}/patched", output_root=f"{THIS_DIR}/generated"
)
start_time = time.time()
render_for_flavor(PALETTE.mocha, state)
render_for_flavor(PALETTE.latte, state)
render_for_flavor(PALETTE.macchiato, state)
render_for_flavor(PALETTE.frappe, state)
for task in state.tasks:
task.wait()
end_time = time.time() - start_time
print(f"Generation complete in {end_time} seconds")
shutil.rmtree(state.input_root)

View File

@@ -7,5 +7,7 @@ pkgs.mkShell {
python311 python311
python311Packages.catppuccin python311Packages.catppuccin
sassc sassc
inkscape
optipng
]; ];
} }