diff --git a/frontend/code/components/icon.ts b/frontend/code/components/icon.ts index 6db6c966..16ddd588 100644 --- a/frontend/code/components/icon.ts +++ b/frontend/code/components/icon.ts @@ -3,6 +3,7 @@ import { ColorSet, ImageFill, LinearGradientFill, + RadialGradientFill, SolidFill, } from "../dataModels"; import { ComponentBase, ComponentState } from "./componentBase"; @@ -11,7 +12,14 @@ import { applyIcon, applyFillToSVG } from "../designApplication"; export type IconState = ComponentState & { _type_: "Icon-builtin"; icon: string; - fill: SolidFill | LinearGradientFill | ImageFill | Color | ColorSet | "dim"; + fill: + | SolidFill + | LinearGradientFill + | RadialGradientFill + | ImageFill + | Color + | ColorSet + | "dim"; }; export class IconComponent extends ComponentBase { diff --git a/frontend/code/cssUtils.ts b/frontend/code/cssUtils.ts index b2f7dabf..ef0f04e8 100644 --- a/frontend/code/cssUtils.ts +++ b/frontend/code/cssUtils.ts @@ -5,7 +5,7 @@ export function colorToCssString(color: Color): string { return `rgba(${r * 255}, ${g * 255}, ${b * 255}, ${a})`; } -function gradientToCssString( +function linearGradientToCssString( angleDegrees: number, stops: [Color, number][] ): string { @@ -22,6 +22,25 @@ function gradientToCssString( )})`; } +function radialGradientToCssString( + centerX: number, + centerY: number, + stops: [Color, number][] +): string { + let stopStrings: string[] = []; + + for (let i = 0; i < stops.length; i++) { + let color = stops[i][0]; + let position = stops[i][1]; + stopStrings.push(`${colorToCssString(color)} ${position * 100}%`); + } + + const centerPosition = `${centerX * 100}% ${centerY * 100}%`; + return `radial-gradient(circle at ${centerPosition}, ${stopStrings.join( + ", " + )})`; +} + export function fillToCss(fill: AnyFill): { background: string; "backdrop-filter": string; @@ -41,7 +60,19 @@ export function fillToCss(fill: AnyFill): { // that the first one is at 0 and the last one is at 1. No need to // verify any of that here. case "linearGradient": - background = gradientToCssString(fill.angleDegrees, fill.stops); + background = linearGradientToCssString( + fill.angleDegrees, + fill.stops + ); + break; + + // Radial Gradient + case "radialGradient": + background = radialGradientToCssString( + fill.centerX, + fill.centerY, + fill.stops + ); break; // Image diff --git a/frontend/code/dataModels.ts b/frontend/code/dataModels.ts index 4e57f9ab..d72f7686 100644 --- a/frontend/code/dataModels.ts +++ b/frontend/code/dataModels.ts @@ -37,6 +37,12 @@ export type LinearGradientFill = { angleDegrees: number; stops: [Color, number][]; }; +export type RadialGradientFill = { + type: "radialGradient"; + centerX: number; + centerY: number; + stops: [Color, number][]; +}; export type ImageFill = { type: "image"; imageUrl: string; @@ -51,6 +57,7 @@ export type FrostedGlassFill = { export type AnyFill = | SolidFill | LinearGradientFill + | RadialGradientFill | ImageFill | FrostedGlassFill; @@ -58,6 +65,7 @@ export type TextCompatibleFill = | Color | SolidFill | LinearGradientFill + | RadialGradientFill | ImageFill | null; export type TextStyle = { diff --git a/frontend/code/designApplication.ts b/frontend/code/designApplication.ts index 6a51e0ec..24309bca 100644 --- a/frontend/code/designApplication.ts +++ b/frontend/code/designApplication.ts @@ -5,6 +5,7 @@ import { ColorSetName, ImageFill, LinearGradientFill, + RadialGradientFill, SolidFill, } from "./dataModels"; import { colorToCssString } from "./cssUtils"; @@ -71,6 +72,7 @@ export function applyFillToSVG( fillLike: | SolidFill | LinearGradientFill + | RadialGradientFill | ImageFill | Color | ColorSet @@ -116,7 +118,11 @@ export function applyFillToSVG( } // Case: Actual Fill object else { - fillLike = fillLike as SolidFill | LinearGradientFill | ImageFill; + fillLike = fillLike as + | SolidFill + | LinearGradientFill + | RadialGradientFill + | ImageFill; switch (fillLike.type) { case "solid": @@ -131,6 +137,15 @@ export function applyFillToSVG( ); break; + case "radialGradient": + styleFill = createRadialGradientFillAndReturnFill( + svgRoot, + fillLike.centerX, + fillLike.centerY, + fillLike.stops + ); + break; + case "image": styleFill = createImageFillAndReturnFill( svgRoot, @@ -172,6 +187,30 @@ function createLinearGradientFillAndReturnFill( return `url(#${gradientId})`; } +function createRadialGradientFillAndReturnFill( + svgRoot: SVGSVGElement, + centerX: number, + centerY: number, + stops: [Color, number][] +): string { + // Create a new radial gradient + const gradientId = generateUniqueId(); + const gradient = createRadialGradient(gradientId, centerX, centerY, stops); + + // Add it to the "defs" section of the SVG + let defs = svgRoot.querySelector("defs"); + + if (defs === null) { + defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + svgRoot.appendChild(defs); + } + + defs.appendChild(gradient); + + // Add the gradient to the path + return `url(#${gradientId})`; +} + function createImageFillAndReturnFill( svgRoot: SVGSVGElement, imageUrl: string, @@ -263,6 +302,45 @@ function createLinearGradient( return gradient; } +function createRadialGradient( + gradientId: string, + centerX: number, + centerY: number, + stops: [Color, number][] +): SVGRadialGradientElement { + const gradient = document.createElementNS( + "http://www.w3.org/2000/svg", + "radialGradient" + ); + gradient.setAttribute("id", gradientId); + gradient.setAttribute("cx", `${centerX}`); + gradient.setAttribute("cy", `${centerY}`); + gradient.setAttribute("r", "0.5"); + gradient.setAttribute("fx", `${centerX}`); + gradient.setAttribute("fy", `${centerY}`); + + let ii = -1; + for (const [color, offset] of stops) { + ii += 1; + + const [r, g, b, a] = color; + const stop = document.createElementNS( + "http://www.w3.org/2000/svg", + "stop" + ); + + stop.setAttribute("offset", `${offset}`); + stop.setAttribute( + "style", + `stop-color: rgba(${r * 255}, ${g * 255}, ${b * 255}, ${a})` + ); + stop.setAttribute("id", `${gradientId}-stop-${ii}`); + gradient.appendChild(stop); + } + + return gradient; +} + // Though the browser caches the icons for us, accessing the cached data // requires an asynchronous function call, which can lead to noticeable delays. // We'll make our own cache so that we can access the icons without ever @@ -292,6 +370,7 @@ export function applyIcon( fill: | SolidFill | LinearGradientFill + | RadialGradientFill | ImageFill | Color | ColorSet diff --git a/package.json b/package.json index 2a426a44..cfb18d3a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ }, "devDependencies": { "sass": "~1.76.0", - "vite": "^5.2.12", + "vite": "^5.4.14", "vite-plugin-compression2": "^1.1.1" } } diff --git a/rio/fills.py b/rio/fills.py index 56abaa87..ea66559d 100644 --- a/rio/fills.py +++ b/rio/fills.py @@ -18,6 +18,7 @@ __all__ = [ "Fill", "ImageFill", "LinearGradientFill", + "RadialGradientFill", "SolidFill", "FrostedGlassFill", ] @@ -219,6 +220,82 @@ class ImageFill(Fill): ) +@dataclasses.dataclass(frozen=True, eq=True) +class RadialGradientFill(Fill): + """ + Fills a shape with a circular gradient. + + `RadialGradientFill` fills the shape with a circular gradient that emanates + from a center point. The gradient can have any number of stops, each with a + color and a position. The gradient will smoothly transition between the + colors at the given positions. The positions are given as fractions, where 0 + is the center of the gradient and 1 is the edge. + + ## Attributes + + `stops`: The different colors that comprise the gradient, along with where + they are positioned. + + The stops are given as tuples. Each tuple contains a color and a + position. The position is a fraction, where 0 is the center of the + gradient and 1 is the edge. + + The order of the stops has no effect. + + There must be at least one stop. + + `center_x`: The x-coordinate of the center of the gradient, as a fraction + of the shape's width. 0.5 is the center of the shape. + + `center_y`: The y-coordinate of the center of the gradient, as a fraction + of the shape's height. 0.5 is the center of the shape. + """ + + stops: tuple[tuple[Color, float], ...] + + center_x: float = 0.5 + center_y: float = 0.5 + + def __init__( + self, + *stops: rio.Color | tuple[rio.Color, float], + center_x: float = 0.5, + center_y: float = 0.5, + ) -> None: + # Postprocess & store the stops + vars(self).update( + stops=utils.verify_and_interpolate_gradient_stops(stops), + center_x=center_x, + center_y=center_y, + ) + + def _as_css_background(self, sess: rio.Session) -> str: + # Special case: Just one color + if len(self.stops) == 1: + return f"#{self.stops[0][0].hexa}" + + # Proper gradient + stop_strings = [] + + for stop in self.stops: + color = stop[0] + position = stop[1] + stop_strings.append(f"#{color.hexa} {position * 100}%") + + center_position = f"{self.center_x * 100}% {self.center_y * 100}%" + return f"radial-gradient(circle at {center_position}, {', '.join(stop_strings)})" + + def _serialize(self, sess: rio.Session) -> Jsonable: + return { + "type": "radialGradient", + "stops": [ + (color.srgba, position) for color, position in self.stops + ], + "centerX": self.center_x, + "centerY": self.center_y, + } + + @dataclasses.dataclass(frozen=True, eq=True) class FrostedGlassFill(Fill): """ @@ -248,5 +325,10 @@ class FrostedGlassFill(Fill): _FillLike: te.TypeAlias = ( - SolidFill | LinearGradientFill | ImageFill | FrostedGlassFill | Color + SolidFill + | LinearGradientFill + | RadialGradientFill + | ImageFill + | FrostedGlassFill + | Color )