diff --git a/.github/workflows/ci.py b/.github/workflows/ci.py index 10b2fe96e..9fc87ac5a 100644 --- a/.github/workflows/ci.py +++ b/.github/workflows/ci.py @@ -188,27 +188,14 @@ def build_kitty() -> None: run(cmd) -def add_to_path(path: str, prepend: bool = False) -> None: - if existing := os.environ.get('PATH') or '': - parts = existing.split(os.pathsep) - parts.insert(0 if prepend else len(parts), path) - seen = set() - ans = [] - for x in parts: - if x not in seen: - seen.add(x) - ans.append(x) - path = os.pathsep.join(ans) - os.environ['PATH'] = path - - def test_kitty() -> None: if is_macos: run('ulimit -c unlimited') run('sudo chmod -R 777 /cores') if running_under_sanitizer: os.environ['MallocNanoZone'] = '0' - add_to_path(os.path.join(SW if is_bundle else SLANG_INSTALL_DIR, 'bin')) + slangc = os.path.join(SW if is_bundle else SLANG_INSTALL_DIR, 'bin', 'slangc') + os.environ['SLANGC'] = slangc run('./test.py', print_crash_reports=True) @@ -230,7 +217,8 @@ def replace_in_file(path: str, src: str, dest: str) -> None: def setup_bundle_env() -> None: global SW - os.environ['SW'] = SW = '/Users/Shared/kitty-build/sw/sw' if is_macos else os.path.join(os.environ['GITHUB_WORKSPACE'], 'sw') + os.environ['SW'] = SW = '/Users/Shared/kitty-build/sw/sw' if is_macos else os.path.join( + os.environ['GITHUB_WORKSPACE'], 'sw') os.environ['PKG_CONFIG_PATH'] = os.path.join(SW, 'lib', 'pkgconfig') if is_macos: os.environ['PATH'] = '{}:{}'.format('/usr/local/opt/sphinx-doc/bin', os.environ['PATH']) diff --git a/kitty/constants.py b/kitty/constants.py index 52756546d..2d14a7007 100644 --- a/kitty/constants.py +++ b/kitty/constants.py @@ -35,7 +35,7 @@ kitty_run_data: dict[str, Any] = getattr(sys, 'kitty_run_data', {}) launched_by_launch_services = kitty_run_data.get('launched_by_launch_services', False) is_quick_access_terminal_app = kitty_run_data.get('is_quick_access_terminal_app', False) unserialize_launch_flag = 'kitty-unserialize-data=' -slangc = ['slang'] +slangc = [os.environ.get('SLANGC', 'slangc')] if getattr(sys, 'frozen', False): diff --git a/kitty/shaders/slang.py b/kitty/shaders/slang.py new file mode 100644 index 000000000..98bb220f0 --- /dev/null +++ b/kitty/shaders/slang.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2026, Kovid Goyal + +# This file is also run as a standalone module from setup.py to compile shaders +# so no top level kitty imports are allowed + +import os +import re +import shutil +from enum import Enum +from functools import lru_cache +from typing import NamedTuple + + +class Stage(Enum): + vertex = 'vertex' + fragment = 'fragment' + + +class EntryPoint(NamedTuple): + stage: Stage + name: str + + +class SlangFile(NamedTuple): + path: str + text: str + imports: frozenset[str] + entry_points: frozenset[EntryPoint] + module: str + + +def parse_slang_text(text: str, path: str = '') -> SlangFile: + text = re.sub(r'/\*[\s\S]*?\*/', '', text) + entry_points, imports = [], set() + module = '' + found_entry_point = '' + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith('//'): + continue + words = line.split() + if found_entry_point: + if words[0].startswith('['): # ] + continue + for q in words: + if '(' in q: + name = q.partition('(')[0] # )) + match found_entry_point: + case 'vertex': + entry_points.append(EntryPoint(Stage.vertex, name)) + case 'fragment' | 'pixel': + entry_points.append(EntryPoint(Stage.fragment, name)) + break + found_entry_point = '' + else: + match words[0]: + case 'module': + module = words[1] + case 'import': + imports.add(words[1]) + case _: + if words[0].startswith('[shader('): # ]) + text = words[0].partition('(')[2].partition(')')[0].strip() + found_entry_point = text[1:-1] + return SlangFile(path, text, frozenset(imports), frozenset(entry_points), module) + + +@lru_cache(4096) +def parse_slang_file(path: str) -> SlangFile: + with open(path) as f: + text = f.read() + return parse_slang_text(text, path) + + +def build_import_graph(dirpath: str) -> dict[str, SlangFile]: + graph: dict[str, SlangFile] = {} + for root, _, files in os.walk(os.path.abspath(dirpath)): + for file in files: + if file.endswith('.slang'): + full_path = os.path.abspath(os.path.join(root, file)) + relpath = os.path.relpath(full_path, root) + modname = os.path.splitext(relpath.replace(os.sep, '.'))[0] + graph[modname] = parse_slang_file(full_path) + return graph + + +def topological_sort(graph: dict[str, SlangFile]) -> list[str]: + visited = set() + order = [] + + def visit(node: str) -> None: + if node in visited or node not in graph: + return + for dep in graph[node].imports: + visit(dep) + visited.add(node) + order.append(node) + + for node in graph: + visit(node) + return order + + +def get_ordered_sources_in_tree(dirpath: str) -> dict[str, SlangFile]: + ans = build_import_graph(dirpath) + topological_sort(ans) + return ans + + + +@lru_cache(2) +def slangc() -> tuple[str, ...]: + try: + from kitty.constants import slangc + except ImportError: + ans = shutil.which('slangc') + if not ans: + raise SystemExit('Could not find the slangc shader compiler on PATH') + slangc = [ans] + return tuple(slangc + ['-std', '2026']) diff --git a/kitty_tests/check_build.py b/kitty_tests/check_build.py index f03bb3666..8e734f25e 100644 --- a/kitty_tests/check_build.py +++ b/kitty_tests/check_build.py @@ -28,7 +28,7 @@ class TestBuild(BaseTest): self.assertTrue(os.access(exe, os.X_OK)) self.assertTrue(os.path.isfile(exe)) self.assertIn(str_version, subprocess.check_output([exe, '--version']).decode()) - self.assertTrue(shutil.which(slangc[0]), f'slang compiler not found on PATH: {slangc[0]}') + self.assertTrue(shutil.which(slangc[0]), f'slang compiler: {slangc[0]} not found on PATH: {os.environ["PATH"]}') def test_loading_extensions(self) -> None: import kitty.fast_data_types as fdt diff --git a/kitty_tests/slang.py b/kitty_tests/slang.py new file mode 100644 index 000000000..9a2a3aad7 --- /dev/null +++ b/kitty_tests/slang.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2026, Kovid Goyal + + +from kitty.shaders.slang import EntryPoint, SlangFile, Stage, parse_slang_text + +from . import BaseTest + + +class TestSlang(BaseTest): + + def test_slang_parser(self): + for src, expected in { + ''' +[shader("vertex")] +void drawTriangle(float4 pos : POSITION) { + // vertex code +} + +[shader("fragment")] +[numthreads(1, 1, 1)] // Handles intermediate attributes seamlessly +float4 psMain() : SV_Target { + return float4(1, 0, 0, 1); +} + ''': SlangFile( + '', '', frozenset(), frozenset({EntryPoint(Stage.vertex, 'drawTriangle'), EntryPoint(Stage.fragment, 'psMain')}), ''), + }.items(): + actual = parse_slang_text(src) + actual = actual._replace(text='') + self.assertEqual(expected, actual) + + def test_slang_ordering(self): + pass # TODO: Test get_ordered_sources_in_tree()