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
182 changes: 182 additions & 0 deletions jumper_wrapper_kernel/icon_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""
Utilities for generating launcher icons for wrapped kernels.
"""

from __future__ import annotations

from importlib import resources
from io import BytesIO
from pathlib import Path
from typing import Optional, Sequence

from PIL import Image
from jupyter_client.kernelspec import KernelSpec


KANGAROO_ASSET_SVG = "kangaroo.svg"


def _open_svg_resource(name: str, size: int) -> Optional[Image.Image]:
"""Rasterize a bundled SVG asset to a square RGBA image of the given size."""
try:
import cairosvg
except ImportError:
return None

try:
resource_path = resources.files(__package__).joinpath(f"data/{name}")
svg_url = str(resource_path)
except AttributeError:
# Python 3.8 fallback
with resources.open_binary(__package__, f"data/{name}") as fh:
svg_bytes = fh.read()
png_bytes = cairosvg.svg2png(bytestring=svg_bytes, output_width=size, output_height=size)
return Image.open(BytesIO(png_bytes)).convert("RGBA")

png_bytes = cairosvg.svg2png(url=svg_url, output_width=size, output_height=size)
return Image.open(BytesIO(png_bytes)).convert("RGBA")


def _rasterize_svg(svg_path: Path, size: int) -> Optional[Image.Image]:
"""Rasterize an SVG to a square RGBA image if cairosvg is available."""
try:
import cairosvg
except ImportError:
return None

png_bytes = cairosvg.svg2png(
url=str(svg_path),
output_width=size,
output_height=size,
)
return Image.open(BytesIO(png_bytes)).convert("RGBA")


def _find_icon_candidate(resource_dir: Path) -> Optional[Path]:
"""Return the first existing icon file in preferred order."""
candidates: Sequence[str] = (
"logo-64x64.png",
"logo-64x64.svg",
"logo-32x32.png",
"logo-32x32.svg",
"logo.png",
)
for filename in candidates:
candidate = resource_dir / filename
if candidate.exists():
return candidate
return None


def _load_base_icon(spec: KernelSpec) -> Optional[Image.Image]:
"""Load a base icon for a kernel spec, resized to 64x64."""
resource_dir = Path(spec.resource_dir)
icon_path = _find_icon_candidate(resource_dir)

if icon_path is None:
return None

if icon_path.suffix.lower() == ".svg":
image = _rasterize_svg(icon_path, size=64)
else:
with Image.open(icon_path) as img:
image = img.copy()

if image is None:
return None

return image.convert("RGBA").resize((64, 64), Image.LANCZOS)


def _overlay_kangaroo(base: Image.Image) -> Image.Image:
"""
Place the kangaroo mark in the top-right corner without covering the base icon.

Strategy: reserve a top-right strip for the badge; shrink and shift the base icon
down/left so they don't overlap.
"""
canvas = Image.new("RGBA", (64, 64), (0, 0, 0, 0))

# Badge sizing
badge_size = 16
badge_margin = 0 # keep it flush to the corner

overlay = _open_svg_resource(KANGAROO_ASSET_SVG, size=badge_size)
if overlay is None: # fallback: no badge
overlay = Image.new("RGBA", (badge_size, badge_size), (0, 0, 0, 0))

overlay.thumbnail((badge_size, badge_size), Image.LANCZOS)
badge_pos = (
canvas.width - overlay.width - badge_margin,
badge_margin,
)

# Base icon: shrink to leave room for badge strip (height=badge_size)
available_width = canvas.width - badge_size - badge_margin
available_height = canvas.height - badge_size - badge_margin

base_safe_width = min(available_width - 4, base.width)
base_safe_height = min(available_height - 4, base.height)
base_resized = base.copy().convert("RGBA").resize(
(base_safe_width, base_safe_height),
Image.LANCZOS,
)
base_pos = (
(available_width - base_safe_width) // 2,
badge_size + badge_margin + 2, # push below the badge area
)

canvas.alpha_composite(base_resized, dest=base_pos)
canvas.alpha_composite(overlay, dest=badge_pos)
return canvas


def create_wrapped_kernel_icons(kernel_dir: Path, wrapped_spec: Optional[KernelSpec], logger=None) -> bool:
"""
Create launcher icons for a wrapped kernel.

Icons are saved as logo-64x64.png and logo-32x32.png inside kernel_dir.
"""
try:
base_icon = _load_base_icon(wrapped_spec) if wrapped_spec else None
except Exception as exc: # pragma: no cover - defensive
if logger:
logger.warning("Failed to load base icon for wrapped kernel: %s", exc)
base_icon = None

if base_icon is None:
base_icon = Image.new("RGBA", (64, 64), (245, 245, 245, 255))

composed = _overlay_kangaroo(base_icon)
kernel_dir.mkdir(parents=True, exist_ok=True)

try:
(kernel_dir / "logo-64x64.png").parent.mkdir(parents=True, exist_ok=True)
composed.save(kernel_dir / "logo-64x64.png")
composed.resize((32, 32), Image.LANCZOS).save(kernel_dir / "logo-32x32.png")
return True
except Exception as exc: # pragma: no cover - defensive
if logger:
logger.warning("Failed to write wrapped kernel icons: %s", exc)
return False


def create_base_kernel_icons(kernel_dir: Path, logger=None) -> bool:
"""
Create launcher icons for the base Jumper Wrapper Kernel itself.

Uses the kangaroo SVG as the full icon (no badge overlay).
"""
try:
full_icon = _open_svg_resource(KANGAROO_ASSET_SVG, size=64)
if full_icon is None:
return False

kernel_dir.mkdir(parents=True, exist_ok=True)
full_icon.save(kernel_dir / "logo-64x64.png")
full_icon.resize((32, 32), Image.LANCZOS).save(kernel_dir / "logo-32x32.png")
return True
except Exception as exc: # pragma: no cover - defensive
if logger:
logger.warning("Failed to write base kernel icons: %s", exc)
return False
9 changes: 8 additions & 1 deletion jumper_wrapper_kernel/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
Installation script for the Jumper Wrapper Kernel.
"""

import argparse
import json
import os
import sys
import argparse
from pathlib import Path

from jupyter_client.kernelspec import KernelSpecManager

from .icon_utils import create_wrapped_kernel_icons, create_base_kernel_icons


KERNEL_JSON = {
"argv": [sys.executable, "-m", "jumper_wrapper_kernel", "-f", "{connection_file}"],
Expand Down Expand Up @@ -36,6 +40,9 @@ def install_kernel(user=True, prefix=None):

with open(kernel_json_path, 'w') as f:
json.dump(KERNEL_JSON, f, indent=2)

# Add branded icons for the base launcher entry (full kangaroo icon)
create_base_kernel_icons(Path(temp_dir))

# Install the kernel spec
if prefix:
Expand Down
10 changes: 10 additions & 0 deletions jumper_wrapper_kernel/kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import sys
from pathlib import Path
from ipykernel.ipkernel import IPythonKernel
from jupyter_client import KernelManager
from jupyter_client.kernelspec import KernelSpecManager
Expand All @@ -14,6 +15,7 @@
from traitlets import Unicode

from .utilities import is_local_magic_cell
from .icon_utils import create_wrapped_kernel_icons


# Check for jumper-extension dependency
Expand Down Expand Up @@ -311,6 +313,7 @@ def _save_wrapped_kernel_spec(self, wrapped_kernel_name, new_kernel_name):
available_kernels = self._get_available_kernels()
wrapped_spec = available_kernels.get(wrapped_kernel_name, {}).get('spec', {})
wrapped_display_name = wrapped_spec.get('display_name', wrapped_kernel_name)
base_kernel_spec = self._kernel_spec_manager.get_kernel_spec(wrapped_kernel_name)

# Create kernel spec directory
kernel_dir = os.path.join(
Expand Down Expand Up @@ -341,6 +344,13 @@ def _save_wrapped_kernel_spec(self, wrapped_kernel_name, new_kernel_name):
kernel_json_path = os.path.join(kernel_dir, 'kernel.json')
with open(kernel_json_path, 'w') as f:
json.dump(kernel_spec, f, indent=2)

# Generate launcher icons with kangaroo badge
create_wrapped_kernel_icons(
Path(kernel_dir),
wrapped_spec=base_kernel_spec,
logger=self.log
)

return True
except Exception as e:
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ dependencies = [
"ipykernel>=6.0",
"jupyter_client>=7.0",
"jumper-extension>=0.3.0",
"Pillow>=10.0.0",
"cairosvg>=2.7.0",
]

[project.optional-dependencies]
Expand All @@ -53,3 +55,6 @@ jumper-wrapper-kernel-install = "jumper_wrapper_kernel.install:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["jumper_wrapper_kernel*"]

[tool.setuptools.package-data]
"jumper_wrapper_kernel" = ["data/*"]