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 @@