Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions assets/winterm/icons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
216 changes: 171 additions & 45 deletions assets/winterm/icons/generate_icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,81 +2,207 @@
# Licensed under the MIT license.

from pathlib import Path
import re
import xml.etree.ElementTree as ElementTree

from PIL import Image, ImageDraw


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),
Expand All @@ -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__":
Expand Down
Binary file modified assets/winterm/icons/winterm-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-150.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-20.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-24.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-310.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-40.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-44.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm-96.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/winterm/icons/winterm.ico
Binary file not shown.
Binary file modified res/terminal/images-WinTerm/LargeTile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified res/terminal/images-WinTerm/SmallTile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified res/terminal/images-WinTerm/Square150x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified res/terminal/images-WinTerm/Square44x44Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified res/terminal/images-WinTerm/StoreLogo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified res/terminal/images-WinTerm/Wide310x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified res/terminal/images-WinTerm/terminal.ico
Binary file not shown.
Binary file modified res/terminal/images-WinTerm/terminal_contrast-black.ico
Binary file not shown.
Binary file modified res/terminal/images-WinTerm/terminal_contrast-white.ico
Binary file not shown.
15 changes: 14 additions & 1 deletion scripts/winterm/verify-branding.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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)
{
Expand All @@ -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('<OCExecutionAliasName Condition="''$(WindowsTerminalBranding)''==''WinTerm''">winterm</OCExecutionAliasName>')) -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'
Expand All @@ -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))
{
Expand Down
Loading