diff --git a/assets/winterm/icons/README.md b/assets/winterm/icons/README.md index 35bc58507..205d2f121 100644 --- a/assets/winterm/icons/README.md +++ b/assets/winterm/icons/README.md @@ -2,7 +2,7 @@ ## Source format -`winterm.svg` is the canonical vector source. Its geometry and color values are mirrored by `generate_icons.py` for deterministic raster generation. +`winterm.svg` is the canonical vector source. `generate_icons.py` parses its geometry and color values directly for deterministic raster generation; it does not keep a second hand-drawn copy of the normal icon. ## Generated sizes @@ -11,7 +11,7 @@ The source asset set contains PNG files at 16, 20, 24, 32, 40, 44, 48, 64, 96, 1 ## Regeneration 1. Install Python 3 and Pillow in an isolated development environment. -2. Update both `winterm.svg` and the matching geometry constants in `generate_icons.py`. +2. Update `winterm.svg`. Keep its intentionally small supported shape vocabulary documented by generator validation. 3. Run `python assets/winterm/icons/generate_icons.py` from any directory. 4. Review every generated size, especially 16, 20, 24, 32, 44, 48, 150, and 310 pixels. 5. Run `scripts/winterm/verify-branding.ps1` before committing the replacement. diff --git a/assets/winterm/icons/generate_icons.py b/assets/winterm/icons/generate_icons.py index 253633234..335f31814 100644 --- a/assets/winterm/icons/generate_icons.py +++ b/assets/winterm/icons/generate_icons.py @@ -2,6 +2,8 @@ # Licensed under the MIT license. from pathlib import Path +import re +import xml.etree.ElementTree as ElementTree from PIL import Image, ImageDraw @@ -9,74 +11,198 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3] SOURCE_DIRECTORY = REPOSITORY_ROOT / "assets" / "winterm" / "icons" PACKAGE_DIRECTORY = REPOSITORY_ROOT / "res" / "terminal" / "images-WinTerm" +SOURCE_SVG = SOURCE_DIRECTORY / "winterm.svg" ICON_SIZES = (16, 20, 24, 32, 40, 44, 48, 64, 96, 128, 150, 256, 310) ICO_SIZES = (16, 20, 24, 32, 40, 48, 64, 96, 256) +SVG_NAMESPACE = "{http://www.w3.org/2000/svg}" -def _palette(variant: str) -> tuple[str, str, str, str, str]: +def _contrast_palette(variant: str) -> tuple[str, str] | None: if variant == "contrast-black": - return ("#FFFFFF", "#000000", "#000000", "#000000", "#000000") + return ("#FFFFFF", "#000000") if variant == "contrast-white": - return ("#000000", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF") - return ("#0B1020", "#23314D", "#F59E0B", "#5EEAD4", "#F8FAFC") - - -def _render_square(size: int, variant: str = "normal") -> Image.Image: + return ("#000000", "#FFFFFF") + return None + + +def _number(element: ElementTree.Element, attribute: str, default: float | None = None) -> float: + raw_value = element.get(attribute) + if raw_value is None: + if default is None: + raise ValueError(f"{element.tag} must define {attribute}.") + return default + return float(raw_value) + + +def _parse_path_points(path_data: str) -> list[tuple[float, float]]: + tokens = re.findall(r"[MLHV]|-?(?:\d+(?:\.\d*)?|\.\d+)", path_data) + points: list[tuple[float, float]] = [] + current = (0.0, 0.0) + index = 0 + while index < len(tokens): + command = tokens[index] + index += 1 + if command in {"M", "L"}: + if index + 1 >= len(tokens): + raise ValueError(f"Incomplete {command} command in SVG path: {path_data}") + current = (float(tokens[index]), float(tokens[index + 1])) + index += 2 + elif command == "H": + if index >= len(tokens): + raise ValueError(f"Incomplete H command in SVG path: {path_data}") + current = (float(tokens[index]), current[1]) + index += 1 + elif command == "V": + if index >= len(tokens): + raise ValueError(f"Incomplete V command in SVG path: {path_data}") + current = (current[0], float(tokens[index])) + index += 1 + else: + raise ValueError( + f"Unsupported SVG path command {command!r}; use only M, L, H, and V." + ) + points.append(current) + if len(points) < 2: + raise ValueError(f"SVG path must contain at least two points: {path_data}") + return points + + +def _load_svg() -> tuple[float, list[ElementTree.Element]]: + document = ElementTree.parse(SOURCE_SVG) + root = document.getroot() + view_box = [float(value) for value in root.get("viewBox", "").split()] + if view_box != [0.0, 0.0, 512.0, 512.0]: + raise ValueError("winterm.svg must keep the canonical 0 0 512 512 viewBox.") + shapes = [ + element + for element in root + if element.tag in {f"{SVG_NAMESPACE}rect", f"{SVG_NAMESPACE}path"} + ] + if not shapes or shapes[0].tag != f"{SVG_NAMESPACE}rect": + raise ValueError("winterm.svg must begin with its background rect.") + return view_box[2], shapes + + +def _render_square( + size: int, + source: tuple[float, list[ElementTree.Element]], + variant: str = "normal", +) -> Image.Image: scale = 4 canvas_size = size * scale image = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0)) draw = ImageDraw.Draw(image) - background, border, rail, prompt, underscore = _palette(variant) - - def point(value: float) -> int: - return round(value * canvas_size / 512) - - draw.rounded_rectangle( - (point(16), point(16), point(496), point(496)), - radius=point(104), - fill=background, - outline=border, - width=max(1, point(16)), - ) - draw.rounded_rectangle( - (point(72), point(128), point(92), point(384)), - radius=max(1, point(10)), - fill=rail, - ) - draw.line( - ((point(140), point(168)), (point(240), point(256)), (point(140), point(344))), - fill=prompt, - width=max(1, point(32)), - joint="curve", - ) - draw.line( - ((point(278), point(344)), (point(398), point(344))), - fill=underscore, - width=max(1, point(32)), - ) + view_box_size, shapes = source + contrast = _contrast_palette(variant) + + def point(value: float) -> float: + return value * canvas_size / view_box_size + + for shape_index, element in enumerate(shapes): + foreground = contrast[1] if contrast else None + if element.tag == f"{SVG_NAMESPACE}rect": + x = point(_number(element, "x")) + y = point(_number(element, "y")) + width = point(_number(element, "width")) + height = point(_number(element, "height")) + radius = point(_number(element, "rx", 0)) + fill = contrast[0] if contrast and shape_index == 0 else foreground + fill = fill or element.get("fill") + stroke = foreground or element.get("stroke") + stroke_width = point(_number(element, "stroke-width", 0)) + + if stroke and stroke_width > 0: + half_stroke = stroke_width / 2 + draw.rounded_rectangle( + ( + x - half_stroke, + y - half_stroke, + x + width + half_stroke, + y + height + half_stroke, + ), + radius=radius + half_stroke, + fill=stroke, + ) + draw.rounded_rectangle( + ( + x + half_stroke, + y + half_stroke, + x + width - half_stroke, + y + height - half_stroke, + ), + radius=max(0, radius - half_stroke), + fill=fill, + ) + else: + draw.rounded_rectangle( + (x, y, x + width, y + height), + radius=radius, + fill=fill, + ) + else: + points = [ + (point(x), point(y)) + for x, y in _parse_path_points(element.get("d", "")) + ] + stroke = foreground or element.get("stroke") + stroke_width = max(1, round(point(_number(element, "stroke-width")))) + draw.line(points, fill=stroke, width=stroke_width, joint="curve") + if element.get("stroke-linecap") == "round": + cap_radius = stroke_width / 2 + for x, y in (points[0], points[-1]): + draw.ellipse( + ( + x - cap_radius, + y - cap_radius, + x + cap_radius, + y + cap_radius, + ), + fill=stroke, + ) + if element.get("stroke-linejoin") == "round": + join_radius = stroke_width / 2 + for x, y in points[1:-1]: + draw.ellipse( + ( + x - join_radius, + y - join_radius, + x + join_radius, + y + join_radius, + ), + fill=stroke, + ) return image.resize((size, size), Image.Resampling.LANCZOS) -def _render_tile(width: int, height: int) -> Image.Image: +def _render_tile( + width: int, + height: int, + source: tuple[float, list[ElementTree.Element]], +) -> Image.Image: image = Image.new("RGBA", (width, height), (0, 0, 0, 0)) side = min(width, height) - icon = _render_square(side) + icon = _render_square(side, source) image.alpha_composite(icon, ((width - side) // 2, (height - side) // 2)) return image -def _write_ico(path: Path, variant: str = "normal") -> None: - image = _render_square(256, variant) +def _write_ico( + path: Path, + source: tuple[float, list[ElementTree.Element]], + variant: str = "normal", +) -> None: + image = _render_square(256, source, variant) image.save(path, format="ICO", sizes=[(size, size) for size in ICO_SIZES]) def main() -> None: SOURCE_DIRECTORY.mkdir(parents=True, exist_ok=True) PACKAGE_DIRECTORY.mkdir(parents=True, exist_ok=True) + source = _load_svg() for size in ICON_SIZES: - _render_square(size).save(SOURCE_DIRECTORY / f"winterm-{size}.png") - _write_ico(SOURCE_DIRECTORY / "winterm.ico") + _render_square(size, source).save(SOURCE_DIRECTORY / f"winterm-{size}.png") + _write_ico(SOURCE_DIRECTORY / "winterm.ico", source) package_tiles = { "StoreLogo.png": (50, 50), @@ -87,11 +213,11 @@ def main() -> None: "LargeTile.png": (310, 310), } for filename, dimensions in package_tiles.items(): - _render_tile(*dimensions).save(PACKAGE_DIRECTORY / filename) + _render_tile(*dimensions, source).save(PACKAGE_DIRECTORY / filename) - _write_ico(PACKAGE_DIRECTORY / "terminal.ico") - _write_ico(PACKAGE_DIRECTORY / "terminal_contrast-black.ico", "contrast-black") - _write_ico(PACKAGE_DIRECTORY / "terminal_contrast-white.ico", "contrast-white") + _write_ico(PACKAGE_DIRECTORY / "terminal.ico", source) + _write_ico(PACKAGE_DIRECTORY / "terminal_contrast-black.ico", source, "contrast-black") + _write_ico(PACKAGE_DIRECTORY / "terminal_contrast-white.ico", source, "contrast-white") if __name__ == "__main__": diff --git a/assets/winterm/icons/winterm-128.png b/assets/winterm/icons/winterm-128.png index 86e97d2fc..602123c8b 100644 Binary files a/assets/winterm/icons/winterm-128.png and b/assets/winterm/icons/winterm-128.png differ diff --git a/assets/winterm/icons/winterm-150.png b/assets/winterm/icons/winterm-150.png index 61c62702a..3c535fdc6 100644 Binary files a/assets/winterm/icons/winterm-150.png and b/assets/winterm/icons/winterm-150.png differ diff --git a/assets/winterm/icons/winterm-16.png b/assets/winterm/icons/winterm-16.png index c2605e0c8..82b5604c1 100644 Binary files a/assets/winterm/icons/winterm-16.png and b/assets/winterm/icons/winterm-16.png differ diff --git a/assets/winterm/icons/winterm-20.png b/assets/winterm/icons/winterm-20.png index 2d46bc694..1bac74f3f 100644 Binary files a/assets/winterm/icons/winterm-20.png and b/assets/winterm/icons/winterm-20.png differ diff --git a/assets/winterm/icons/winterm-24.png b/assets/winterm/icons/winterm-24.png index 47fc2d606..ff8662a98 100644 Binary files a/assets/winterm/icons/winterm-24.png and b/assets/winterm/icons/winterm-24.png differ diff --git a/assets/winterm/icons/winterm-256.png b/assets/winterm/icons/winterm-256.png index e055f7f42..764e4275e 100644 Binary files a/assets/winterm/icons/winterm-256.png and b/assets/winterm/icons/winterm-256.png differ diff --git a/assets/winterm/icons/winterm-310.png b/assets/winterm/icons/winterm-310.png index 8a95843f8..c852ecdef 100644 Binary files a/assets/winterm/icons/winterm-310.png and b/assets/winterm/icons/winterm-310.png differ diff --git a/assets/winterm/icons/winterm-32.png b/assets/winterm/icons/winterm-32.png index 176082e78..05c7951f3 100644 Binary files a/assets/winterm/icons/winterm-32.png and b/assets/winterm/icons/winterm-32.png differ diff --git a/assets/winterm/icons/winterm-40.png b/assets/winterm/icons/winterm-40.png index 133f58f13..1aa6d1201 100644 Binary files a/assets/winterm/icons/winterm-40.png and b/assets/winterm/icons/winterm-40.png differ diff --git a/assets/winterm/icons/winterm-44.png b/assets/winterm/icons/winterm-44.png index ebc861470..f2ec33f57 100644 Binary files a/assets/winterm/icons/winterm-44.png and b/assets/winterm/icons/winterm-44.png differ diff --git a/assets/winterm/icons/winterm-48.png b/assets/winterm/icons/winterm-48.png index 2a14bf82a..af74e2988 100644 Binary files a/assets/winterm/icons/winterm-48.png and b/assets/winterm/icons/winterm-48.png differ diff --git a/assets/winterm/icons/winterm-64.png b/assets/winterm/icons/winterm-64.png index 3db8385b6..eaafddc81 100644 Binary files a/assets/winterm/icons/winterm-64.png and b/assets/winterm/icons/winterm-64.png differ diff --git a/assets/winterm/icons/winterm-96.png b/assets/winterm/icons/winterm-96.png index 4f6e6fd75..b2beb0f7a 100644 Binary files a/assets/winterm/icons/winterm-96.png and b/assets/winterm/icons/winterm-96.png differ diff --git a/assets/winterm/icons/winterm.ico b/assets/winterm/icons/winterm.ico index 4d487f5d5..5e5da4f39 100644 Binary files a/assets/winterm/icons/winterm.ico and b/assets/winterm/icons/winterm.ico differ diff --git a/res/terminal/images-WinTerm/LargeTile.png b/res/terminal/images-WinTerm/LargeTile.png index 8a95843f8..c852ecdef 100644 Binary files a/res/terminal/images-WinTerm/LargeTile.png and b/res/terminal/images-WinTerm/LargeTile.png differ diff --git a/res/terminal/images-WinTerm/SmallTile.png b/res/terminal/images-WinTerm/SmallTile.png index a2bd6498b..6a7a2d3d5 100644 Binary files a/res/terminal/images-WinTerm/SmallTile.png and b/res/terminal/images-WinTerm/SmallTile.png differ diff --git a/res/terminal/images-WinTerm/Square150x150Logo.png b/res/terminal/images-WinTerm/Square150x150Logo.png index 61c62702a..3c535fdc6 100644 Binary files a/res/terminal/images-WinTerm/Square150x150Logo.png and b/res/terminal/images-WinTerm/Square150x150Logo.png differ diff --git a/res/terminal/images-WinTerm/Square44x44Logo.png b/res/terminal/images-WinTerm/Square44x44Logo.png index ebc861470..f2ec33f57 100644 Binary files a/res/terminal/images-WinTerm/Square44x44Logo.png and b/res/terminal/images-WinTerm/Square44x44Logo.png differ diff --git a/res/terminal/images-WinTerm/StoreLogo.png b/res/terminal/images-WinTerm/StoreLogo.png index b08ea3dd1..829b4e4fe 100644 Binary files a/res/terminal/images-WinTerm/StoreLogo.png and b/res/terminal/images-WinTerm/StoreLogo.png differ diff --git a/res/terminal/images-WinTerm/Wide310x150Logo.png b/res/terminal/images-WinTerm/Wide310x150Logo.png index 96012918a..95994e6c2 100644 Binary files a/res/terminal/images-WinTerm/Wide310x150Logo.png and b/res/terminal/images-WinTerm/Wide310x150Logo.png differ diff --git a/res/terminal/images-WinTerm/terminal.ico b/res/terminal/images-WinTerm/terminal.ico index 4d487f5d5..5e5da4f39 100644 Binary files a/res/terminal/images-WinTerm/terminal.ico and b/res/terminal/images-WinTerm/terminal.ico differ diff --git a/res/terminal/images-WinTerm/terminal_contrast-black.ico b/res/terminal/images-WinTerm/terminal_contrast-black.ico index c6a6e2955..dddea326f 100644 Binary files a/res/terminal/images-WinTerm/terminal_contrast-black.ico and b/res/terminal/images-WinTerm/terminal_contrast-black.ico differ diff --git a/res/terminal/images-WinTerm/terminal_contrast-white.ico b/res/terminal/images-WinTerm/terminal_contrast-white.ico index 515abcf73..8a397f3ac 100644 Binary files a/res/terminal/images-WinTerm/terminal_contrast-white.ico and b/res/terminal/images-WinTerm/terminal_contrast-white.ico differ diff --git a/scripts/winterm/verify-branding.ps1 b/scripts/winterm/verify-branding.ps1 index 74e0407a9..464bc74f2 100644 --- a/scripts/winterm/verify-branding.ps1 +++ b/scripts/winterm/verify-branding.ps1 @@ -204,6 +204,7 @@ try 'THIRD_PARTY_NOTICES.md', 'src\cascadia\CascadiaPackage\NOTICE.html', 'assets\winterm\licenses\open-source-licenses.html', + 'assets\winterm\icons\generate_icons.py', 'assets\winterm\icons\winterm.svg', 'assets\winterm\icons\winterm.ico', 'res\terminal\images-WinTerm\StoreLogo.png', @@ -212,7 +213,9 @@ try 'res\terminal\images-WinTerm\SmallTile.png', 'res\terminal\images-WinTerm\Wide310x150Logo.png', 'res\terminal\images-WinTerm\LargeTile.png', - 'res\terminal\images-WinTerm\terminal.ico' + 'res\terminal\images-WinTerm\terminal.ico', + 'res\terminal\images-WinTerm\terminal_contrast-black.ico', + 'res\terminal\images-WinTerm\terminal_contrast-white.ico' ) foreach ($relativePath in $requiredFiles) { @@ -232,6 +235,11 @@ try $wingetGenerator = Get-Content -LiteralPath (Join-Path $repositoryRoot 'scripts\winterm\generate-winget-manifests.ps1') -Raw $releaseGenerator = Get-Content -LiteralPath (Join-Path $repositoryRoot 'scripts\winterm\generate-release-artifacts.ps1') -Raw $updateGenerator = Get-Content -LiteralPath (Join-Path $repositoryRoot 'scripts\winterm\generate-stable-update-manifest.ps1') -Raw + $iconGenerator = Get-Content -LiteralPath (Join-Path $repositoryRoot 'assets\winterm\icons\generate_icons.py') -Raw + $terminalResources = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\cascadia\WindowsTerminal\WindowsTerminal.rc') -Raw + $windowIconRuntime = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\cascadia\WindowsTerminal\icon.cpp') -Raw + $islandWindow = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\cascadia\WindowsTerminal\IslandWindow.cpp') -Raw + $titlebarControl = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\cascadia\TerminalApp\TitlebarControl.cpp') -Raw Test-Requirement -Condition ($packageProject.Contains('Package-winTerm.appxmanifest') -and $packageProject.Contains('winterm')) -Message 'Package project selects the winTerm manifest and launcher alias' Test-Requirement -Condition ($brandingTargets.Contains('WT_BRANDING_WINTERM')) -Message 'Dedicated winTerm compile-time branding token exists' @@ -248,6 +256,11 @@ try Test-Requirement -Condition ($wingetGenerator.Contains('Publisher: helloThisWorld') -and $wingetGenerator.Contains('PackageIdentifier: HelloThisWorld.winTerm') -and $wingetGenerator.Contains('InstallerType: inno')) -Message 'WinGet generation uses protected publisher, product ID, and Inno type' Test-Requirement -Condition ($releaseGenerator.Contains("publisher = 'helloThisWorld'") -and $releaseGenerator.Contains("productId = 'HelloThisWorld.winTerm'")) -Message 'Release SBOM and metadata generation use protected winTerm identity' Test-Requirement -Condition ($updateGenerator.Contains("publisher = 'helloThisWorld'") -and $updateGenerator.Contains("productId = 'HelloThisWorld.winTerm'")) -Message 'Update metadata generation uses protected winTerm identity' + Test-Requirement -Condition ($iconGenerator.Contains('SOURCE_SVG = SOURCE_DIRECTORY / "winterm.svg"') -and $iconGenerator.Contains('ElementTree.parse(SOURCE_SVG)')) -Message 'Generated application artwork is parsed from the canonical winterm.svg source' + Test-Requirement -Condition ($terminalResources -match '(?s)#if defined\(WT_BRANDING_WINTERM\).*images-WinTerm\\\\terminal\.ico.*images-WinTerm\\\\terminal_contrast-black\.ico.*images-WinTerm\\\\terminal_contrast-white\.ico.*#elif defined\(WT_BRANDING_RELEASE\)') -Message 'WinTerm executable resources select normal and High Contrast canonical icons only in the WinTerm branding branch' + Test-Requirement -Condition ($windowIconRuntime.Contains('WM_SETICON') -and $islandWindow.Contains('UpdateWindowIconForActiveMetrics(_window.get())')) -Message 'Runtime applies WinTerm small and large window icons after HWND creation' + Test-Requirement -Condition ($titlebarControl.Contains('ms-appx:///Images/Square44x44Logo.png') -and $titlebarControl.Contains('#if defined(WT_BRANDING_WINTERM)')) -Message 'Custom titlebar displays the packaged WinTerm icon only for WinTerm branding' + Test-Requirement -Condition ($installerDefinition.Contains('SetupIconFile=..\..\assets\winterm\icons\winterm.ico') -and $installerDefinition.Contains('Filename: "{app}\winTerm.exe"')) -Message 'Installer and installed shortcuts use canonical WinTerm icon sources' if (-not [string]::IsNullOrWhiteSpace($PackageOutput)) { diff --git a/src/cascadia/TerminalApp/App.xaml b/src/cascadia/TerminalApp/App.xaml index 17e5d54ac..182ee0342 100644 --- a/src/cascadia/TerminalApp/App.xaml +++ b/src/cascadia/TerminalApp/App.xaml @@ -49,11 +49,15 @@ 1,1,1,0 35 - 32 + 22 + 42 + 32 + 27 + 27 27 5 - - - 40.0 + 32.0 @@ -271,9 +272,9 @@