|
| 1 | +import { wrapText } from '../utils'; |
| 2 | + |
| 3 | +// Helper to draw a rounded rect path without filling/stroking |
| 4 | +const roundRectPath = (ctx, x, y, w, h, r) => { |
| 5 | + ctx.beginPath(); |
| 6 | + ctx.roundRect(x, y, w, h, r); |
| 7 | +}; |
| 8 | + |
| 9 | +export const macosGlass = (ctx, width, height, scale, data) => { |
| 10 | + const { |
| 11 | + primaryColor, |
| 12 | + secondaryColor, |
| 13 | + bgColor, |
| 14 | + showPattern, |
| 15 | + repoOwner, |
| 16 | + repoName, |
| 17 | + description, |
| 18 | + language, |
| 19 | + stars, |
| 20 | + forks, |
| 21 | + supportUrl, |
| 22 | + } = data; |
| 23 | + |
| 24 | + // --- Wallpaper background --- |
| 25 | + // Soft gradient mesh inspired by macOS Sequoia wallpaper |
| 26 | + ctx.save(); |
| 27 | + ctx.fillStyle = bgColor; |
| 28 | + ctx.fillRect(0, 0, width, height); |
| 29 | + |
| 30 | + // Large blurred color blobs |
| 31 | + ctx.filter = 'blur(120px)'; |
| 32 | + ctx.globalAlpha = 0.5; |
| 33 | + |
| 34 | + ctx.fillStyle = primaryColor; |
| 35 | + ctx.beginPath(); |
| 36 | + ctx.ellipse(width * 0.2, height * 0.3, 400 * scale, 300 * scale, 0.3, 0, Math.PI * 2); |
| 37 | + ctx.fill(); |
| 38 | + |
| 39 | + ctx.fillStyle = secondaryColor; |
| 40 | + ctx.beginPath(); |
| 41 | + ctx.ellipse(width * 0.75, height * 0.25, 350 * scale, 250 * scale, -0.2, 0, Math.PI * 2); |
| 42 | + ctx.fill(); |
| 43 | + |
| 44 | + ctx.fillStyle = primaryColor; |
| 45 | + ctx.globalAlpha = 0.35; |
| 46 | + ctx.beginPath(); |
| 47 | + ctx.ellipse(width * 0.5, height * 0.85, 450 * scale, 200 * scale, 0, 0, Math.PI * 2); |
| 48 | + ctx.fill(); |
| 49 | + |
| 50 | + ctx.fillStyle = secondaryColor; |
| 51 | + ctx.globalAlpha = 0.25; |
| 52 | + ctx.beginPath(); |
| 53 | + ctx.ellipse(width * 0.85, height * 0.7, 300 * scale, 250 * scale, 0.5, 0, Math.PI * 2); |
| 54 | + ctx.fill(); |
| 55 | + |
| 56 | + ctx.restore(); |
| 57 | + |
| 58 | + // --- Subtle grid pattern (showPattern) --- |
| 59 | + if (showPattern) { |
| 60 | + ctx.save(); |
| 61 | + ctx.globalAlpha = 0.03; |
| 62 | + ctx.strokeStyle = '#ffffff'; |
| 63 | + ctx.lineWidth = 1 * scale; |
| 64 | + const gridSize = 50 * scale; |
| 65 | + for (let x = 0; x < width; x += gridSize) { |
| 66 | + ctx.beginPath(); |
| 67 | + ctx.moveTo(x, 0); |
| 68 | + ctx.lineTo(x, height); |
| 69 | + ctx.stroke(); |
| 70 | + } |
| 71 | + for (let y = 0; y < height; y += gridSize) { |
| 72 | + ctx.beginPath(); |
| 73 | + ctx.moveTo(0, y); |
| 74 | + ctx.lineTo(width, y); |
| 75 | + ctx.stroke(); |
| 76 | + } |
| 77 | + ctx.restore(); |
| 78 | + } |
| 79 | + |
| 80 | + // --- Main glass window --- |
| 81 | + const winW = width * 0.62; |
| 82 | + const winH = height * 0.72; |
| 83 | + const winX = (width - winW) / 2; |
| 84 | + const winY = (height - winH) / 2 + 10 * scale; |
| 85 | + const winR = 16 * scale; |
| 86 | + |
| 87 | + // Window shadow |
| 88 | + ctx.save(); |
| 89 | + ctx.filter = 'blur(40px)'; |
| 90 | + ctx.fillStyle = 'rgba(0,0,0,0.35)'; |
| 91 | + roundRectPath(ctx, winX + 4 * scale, winY + 8 * scale, winW, winH, winR); |
| 92 | + ctx.fill(); |
| 93 | + ctx.restore(); |
| 94 | + |
| 95 | + // Glass fill (frosted translucent) |
| 96 | + ctx.save(); |
| 97 | + ctx.fillStyle = 'rgba(255,255,255,0.12)'; |
| 98 | + roundRectPath(ctx, winX, winY, winW, winH, winR); |
| 99 | + ctx.fill(); |
| 100 | + ctx.restore(); |
| 101 | + |
| 102 | + // Glass inner highlight (top edge glow) |
| 103 | + ctx.save(); |
| 104 | + const highlightGrad = ctx.createLinearGradient(0, winY, 0, winY + 60 * scale); |
| 105 | + highlightGrad.addColorStop(0, 'rgba(255,255,255,0.15)'); |
| 106 | + highlightGrad.addColorStop(1, 'rgba(255,255,255,0)'); |
| 107 | + ctx.fillStyle = highlightGrad; |
| 108 | + roundRectPath(ctx, winX, winY, winW, 60 * scale, [winR, winR, 0, 0]); |
| 109 | + ctx.fill(); |
| 110 | + ctx.restore(); |
| 111 | + |
| 112 | + // Glass border |
| 113 | + ctx.save(); |
| 114 | + ctx.strokeStyle = 'rgba(255,255,255,0.18)'; |
| 115 | + ctx.lineWidth = 1.5 * scale; |
| 116 | + roundRectPath(ctx, winX, winY, winW, winH, winR); |
| 117 | + ctx.stroke(); |
| 118 | + ctx.restore(); |
| 119 | + |
| 120 | + // --- Title bar --- |
| 121 | + const titleBarH = 52 * scale; |
| 122 | + const tbY = winY; |
| 123 | + |
| 124 | + // Title bar separator |
| 125 | + ctx.save(); |
| 126 | + ctx.fillStyle = 'rgba(255,255,255,0.06)'; |
| 127 | + ctx.fillRect(winX, tbY + titleBarH, winW, 1 * scale); |
| 128 | + ctx.restore(); |
| 129 | + |
| 130 | + // Traffic lights |
| 131 | + const tlY = tbY + titleBarH / 2; |
| 132 | + const tlStartX = winX + 22 * scale; |
| 133 | + const tlR = 7 * scale; |
| 134 | + const tlGap = 22 * scale; |
| 135 | + |
| 136 | + const trafficColors = ['#ff5f57', '#febc2e', '#28c840']; |
| 137 | + trafficColors.forEach((color, i) => { |
| 138 | + const tx = tlStartX + i * tlGap; |
| 139 | + ctx.save(); |
| 140 | + ctx.fillStyle = color; |
| 141 | + ctx.beginPath(); |
| 142 | + ctx.arc(tx, tlY, tlR, 0, Math.PI * 2); |
| 143 | + ctx.fill(); |
| 144 | + |
| 145 | + // Subtle inner shadow |
| 146 | + ctx.globalAlpha = 0.3; |
| 147 | + ctx.fillStyle = 'rgba(0,0,0,0.15)'; |
| 148 | + ctx.beginPath(); |
| 149 | + ctx.arc(tx, tlY + 1 * scale, tlR - 1 * scale, 0, Math.PI * 2); |
| 150 | + ctx.fill(); |
| 151 | + ctx.restore(); |
| 152 | + }); |
| 153 | + |
| 154 | + // Window title (repo name in title bar) |
| 155 | + ctx.save(); |
| 156 | + ctx.textAlign = 'center'; |
| 157 | + ctx.fillStyle = 'rgba(255,255,255,0.5)'; |
| 158 | + ctx.font = `500 ${14 * scale}px -apple-system, "SF Pro Text", "Inter", sans-serif`; |
| 159 | + ctx.fillText(`${repoOwner}/${repoName}`, winX + winW / 2, tlY + 5 * scale); |
| 160 | + ctx.restore(); |
| 161 | + |
| 162 | + // --- Window content --- |
| 163 | + const contentX = winX + 36 * scale; |
| 164 | + const contentY = tbY + titleBarH + 30 * scale; |
| 165 | + const contentW = winW - 72 * scale; |
| 166 | + |
| 167 | + // Owner |
| 168 | + ctx.save(); |
| 169 | + ctx.textAlign = 'left'; |
| 170 | + ctx.fillStyle = secondaryColor; |
| 171 | + ctx.globalAlpha = 0.7; |
| 172 | + ctx.font = `500 ${16 * scale}px "JetBrains Mono", monospace`; |
| 173 | + ctx.fillText(repoOwner, contentX, contentY); |
| 174 | + ctx.restore(); |
| 175 | + |
| 176 | + // Repo name — large |
| 177 | + ctx.save(); |
| 178 | + ctx.fillStyle = '#ffffff'; |
| 179 | + ctx.font = `700 ${52 * scale}px -apple-system, "SF Pro Display", "Inter", sans-serif`; |
| 180 | + let fontSize = 52; |
| 181 | + while (ctx.measureText(repoName).width > contentW && fontSize > 28) { |
| 182 | + fontSize -= 2; |
| 183 | + ctx.font = `700 ${fontSize * scale}px -apple-system, "SF Pro Display", "Inter", sans-serif`; |
| 184 | + } |
| 185 | + ctx.fillText(repoName, contentX, contentY + 52 * scale); |
| 186 | + ctx.restore(); |
| 187 | + |
| 188 | + // Description |
| 189 | + ctx.save(); |
| 190 | + ctx.fillStyle = 'rgba(255,255,255,0.55)'; |
| 191 | + ctx.font = `400 ${18 * scale}px -apple-system, "SF Pro Text", "Inter", sans-serif`; |
| 192 | + wrapText(ctx, description, contentX, contentY + 86 * scale, contentW, 26 * scale); |
| 193 | + ctx.restore(); |
| 194 | + |
| 195 | + // --- Pill badges row --- |
| 196 | + const badgeY = contentY + 165 * scale; |
| 197 | + let badgeX = contentX; |
| 198 | + |
| 199 | + const drawGlassPill = (text, color) => { |
| 200 | + ctx.save(); |
| 201 | + ctx.font = `600 ${13 * scale}px -apple-system, "SF Pro Text", "Inter", sans-serif`; |
| 202 | + const tw = ctx.measureText(text).width; |
| 203 | + const dotSpace = 22 * scale; // space for the colored dot + gap |
| 204 | + const hPad = 14 * scale; |
| 205 | + const pillW = dotSpace + tw + hPad * 2; |
| 206 | + const pillH = 34 * scale; |
| 207 | + const pillR = pillH / 2; |
| 208 | + |
| 209 | + // Pill glass bg |
| 210 | + ctx.fillStyle = 'rgba(255,255,255,0.08)'; |
| 211 | + roundRectPath(ctx, badgeX, badgeY, pillW, pillH, pillR); |
| 212 | + ctx.fill(); |
| 213 | + |
| 214 | + // Pill border |
| 215 | + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; |
| 216 | + ctx.lineWidth = 1 * scale; |
| 217 | + roundRectPath(ctx, badgeX, badgeY, pillW, pillH, pillR); |
| 218 | + ctx.stroke(); |
| 219 | + |
| 220 | + // Colored dot |
| 221 | + ctx.fillStyle = color; |
| 222 | + ctx.beginPath(); |
| 223 | + ctx.arc(badgeX + hPad + 4 * scale, badgeY + pillH / 2, 4 * scale, 0, Math.PI * 2); |
| 224 | + ctx.fill(); |
| 225 | + |
| 226 | + // Text (vertically centered using textBaseline) |
| 227 | + ctx.textBaseline = 'middle'; |
| 228 | + ctx.fillStyle = 'rgba(255,255,255,0.7)'; |
| 229 | + ctx.fillText(text, badgeX + hPad + dotSpace, badgeY + pillH / 2); |
| 230 | + |
| 231 | + ctx.restore(); |
| 232 | + badgeX += pillW + 10 * scale; |
| 233 | + }; |
| 234 | + |
| 235 | + drawGlassPill(language, primaryColor); |
| 236 | + if (stars) drawGlassPill(`★ ${stars}`, '#febc2e'); |
| 237 | + if (forks) drawGlassPill(`⑂ ${forks}`, secondaryColor); |
| 238 | + |
| 239 | + // --- Color palette dots (bottom of window) --- |
| 240 | + const paletteY = winY + winH - 44 * scale; |
| 241 | + let dotX = contentX; |
| 242 | + |
| 243 | + ctx.save(); |
| 244 | + ctx.fillStyle = 'rgba(255,255,255,0.2)'; |
| 245 | + ctx.font = `500 ${10 * scale}px "JetBrains Mono", monospace`; |
| 246 | + ctx.textAlign = 'left'; |
| 247 | + ctx.fillText('THEME', dotX, paletteY - 2 * scale); |
| 248 | + dotX += 55 * scale; |
| 249 | + |
| 250 | + [bgColor, primaryColor, secondaryColor].forEach((color) => { |
| 251 | + // Dot border |
| 252 | + ctx.save(); |
| 253 | + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; |
| 254 | + ctx.lineWidth = 1.5 * scale; |
| 255 | + ctx.beginPath(); |
| 256 | + ctx.arc(dotX, paletteY - 6 * scale, 8 * scale, 0, Math.PI * 2); |
| 257 | + ctx.stroke(); |
| 258 | + ctx.fillStyle = color; |
| 259 | + ctx.fill(); |
| 260 | + ctx.restore(); |
| 261 | + dotX += 26 * scale; |
| 262 | + }); |
| 263 | + |
| 264 | + // Pattern status |
| 265 | + dotX += 16 * scale; |
| 266 | + ctx.fillStyle = showPattern ? primaryColor : 'rgba(255,255,255,0.15)'; |
| 267 | + ctx.font = `500 ${10 * scale}px "JetBrains Mono", monospace`; |
| 268 | + ctx.fillText(showPattern ? '● Grid' : '○ Grid', dotX, paletteY - 2 * scale); |
| 269 | + ctx.restore(); |
| 270 | + |
| 271 | + // Support URL (bottom right of window) |
| 272 | + if (supportUrl) { |
| 273 | + ctx.save(); |
| 274 | + ctx.textAlign = 'right'; |
| 275 | + ctx.fillStyle = 'rgba(255,255,255,0.2)'; |
| 276 | + ctx.font = `400 ${12 * scale}px -apple-system, "SF Pro Text", "Inter", sans-serif`; |
| 277 | + ctx.fillText(supportUrl, winX + winW - 36 * scale, paletteY - 2 * scale); |
| 278 | + ctx.restore(); |
| 279 | + } |
| 280 | + |
| 281 | + // --- Floating mini widget (top-right, outside window) --- |
| 282 | + const widgetW = 160 * scale; |
| 283 | + const widgetH = 70 * scale; |
| 284 | + const widgetX = winX + winW + 20 * scale; |
| 285 | + const widgetY = winY + 30 * scale; |
| 286 | + |
| 287 | + // Only draw if it fits |
| 288 | + if (widgetX + widgetW < width - 20 * scale) { |
| 289 | + // Widget shadow |
| 290 | + ctx.save(); |
| 291 | + ctx.filter = 'blur(20px)'; |
| 292 | + ctx.fillStyle = 'rgba(0,0,0,0.25)'; |
| 293 | + roundRectPath(ctx, widgetX + 2 * scale, widgetY + 4 * scale, widgetW, widgetH, 12 * scale); |
| 294 | + ctx.fill(); |
| 295 | + ctx.restore(); |
| 296 | + |
| 297 | + // Widget glass |
| 298 | + ctx.save(); |
| 299 | + ctx.fillStyle = 'rgba(255,255,255,0.1)'; |
| 300 | + roundRectPath(ctx, widgetX, widgetY, widgetW, widgetH, 12 * scale); |
| 301 | + ctx.fill(); |
| 302 | + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; |
| 303 | + ctx.lineWidth = 1 * scale; |
| 304 | + roundRectPath(ctx, widgetX, widgetY, widgetW, widgetH, 12 * scale); |
| 305 | + ctx.stroke(); |
| 306 | + ctx.restore(); |
| 307 | + |
| 308 | + // Widget content — stars count |
| 309 | + ctx.save(); |
| 310 | + ctx.textAlign = 'left'; |
| 311 | + ctx.fillStyle = 'rgba(255,255,255,0.35)'; |
| 312 | + ctx.font = `500 ${10 * scale}px -apple-system, "SF Pro Text", "Inter", sans-serif`; |
| 313 | + ctx.fillText('Stargazers', widgetX + 16 * scale, widgetY + 22 * scale); |
| 314 | + ctx.fillStyle = '#ffffff'; |
| 315 | + ctx.font = `700 ${26 * scale}px -apple-system, "SF Pro Display", "Inter", sans-serif`; |
| 316 | + ctx.fillText(stars || '0', widgetX + 16 * scale, widgetY + 52 * scale); |
| 317 | + ctx.restore(); |
| 318 | + } |
| 319 | + |
| 320 | + // --- Floating mini widget (bottom-left, outside window) --- |
| 321 | + const widget2W = 140 * scale; |
| 322 | + const widget2H = 60 * scale; |
| 323 | + const widget2X = winX - widget2W - 20 * scale; |
| 324 | + const widget2Y = winY + winH - widget2H - 40 * scale; |
| 325 | + |
| 326 | + if (widget2X > 20 * scale) { |
| 327 | + ctx.save(); |
| 328 | + ctx.filter = 'blur(20px)'; |
| 329 | + ctx.fillStyle = 'rgba(0,0,0,0.25)'; |
| 330 | + roundRectPath(ctx, widget2X + 2 * scale, widget2Y + 4 * scale, widget2W, widget2H, 12 * scale); |
| 331 | + ctx.fill(); |
| 332 | + ctx.restore(); |
| 333 | + |
| 334 | + ctx.save(); |
| 335 | + ctx.fillStyle = 'rgba(255,255,255,0.1)'; |
| 336 | + roundRectPath(ctx, widget2X, widget2Y, widget2W, widget2H, 12 * scale); |
| 337 | + ctx.fill(); |
| 338 | + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; |
| 339 | + ctx.lineWidth = 1 * scale; |
| 340 | + roundRectPath(ctx, widget2X, widget2Y, widget2W, widget2H, 12 * scale); |
| 341 | + ctx.stroke(); |
| 342 | + ctx.restore(); |
| 343 | + |
| 344 | + ctx.save(); |
| 345 | + ctx.textAlign = 'left'; |
| 346 | + ctx.fillStyle = 'rgba(255,255,255,0.35)'; |
| 347 | + ctx.font = `500 ${10 * scale}px -apple-system, "SF Pro Text", "Inter", sans-serif`; |
| 348 | + ctx.fillText('Language', widget2X + 14 * scale, widget2Y + 22 * scale); |
| 349 | + ctx.fillStyle = primaryColor; |
| 350 | + ctx.font = `700 ${20 * scale}px -apple-system, "SF Pro Display", "Inter", sans-serif`; |
| 351 | + ctx.fillText(language, widget2X + 14 * scale, widget2Y + 46 * scale); |
| 352 | + ctx.restore(); |
| 353 | + } |
| 354 | +}; |
0 commit comments