From 5cd52f278596fc3e5459447a88280c013384f949 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 10 Feb 2026 20:07:00 +0100 Subject: [PATCH] feat: Enhance UI components with full labels and abbreviations, and add port mapping to dev docker-compose. --- docker-compose.dev.yml | 10 ++ src/components/ControlPanel.astro | 68 ++++++---- src/components/TuiButton.astro | 46 +++++-- src/components/TuiSegment.astro | 41 ++++-- src/components/TuiSlider.astro | 110 +++++++++++++--- src/components/TuiToggle.astro | 40 ++++-- src/pages/index.astro | 18 ++- src/scripts/ascii-controller.ts | 29 ++++- src/scripts/ui-bindings.ts | 10 ++ src/scripts/webgl-ascii.ts | 208 +++++------------------------- src/styles/global.css | 4 +- 11 files changed, 332 insertions(+), 252 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 32bdbf3..94d18f0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,9 +1,19 @@ services: web: build: . + ports: + - "4321:4321" volumes: - .:/app - /app/node_modules command: npm run dev -- --host environment: - NODE_ENV=development + + caddy: + image: hello-world + entrypoint: ["true"] + restart: "no" + ports: [] + volumes: [] + depends_on: [] diff --git a/src/components/ControlPanel.astro b/src/components/ControlPanel.astro index ba9ed12..d71ff3b 100644 --- a/src/components/ControlPanel.astro +++ b/src/components/ControlPanel.astro @@ -20,7 +20,8 @@ import { ChevronDown } from "@lucide/astro";
@@ -182,7 +196,8 @@ import { ChevronDown } from "@lucide/astro"; @@ -194,7 +209,8 @@ import { ChevronDown } from "@lucide/astro";
diff --git a/src/components/TuiButton.astro b/src/components/TuiButton.astro index 4cc5c01..ab994f0 100644 --- a/src/components/TuiButton.astro +++ b/src/components/TuiButton.astro @@ -2,6 +2,7 @@ interface Props { id: string; label: string; + abbr?: string; shortcut?: string; variant?: "default" | "primary" | "subtle"; title?: string; @@ -11,6 +12,7 @@ interface Props { const { id, label, + abbr, shortcut, variant = "default", title = "", @@ -26,7 +28,10 @@ const { data-tooltip-desc={description} > {shortcut && {shortcut}} - {label} + + {label} + {abbr && {abbr}} + diff --git a/src/components/TuiSegment.astro b/src/components/TuiSegment.astro index f87c5bb..e380e3f 100644 --- a/src/components/TuiSegment.astro +++ b/src/components/TuiSegment.astro @@ -2,6 +2,7 @@ interface Props { id: string; label: string; + abbr?: string; options: string[]; value?: string; title?: string; @@ -11,6 +12,7 @@ interface Props { const { id, label, + abbr, options, value = options[0], title = "", @@ -24,7 +26,10 @@ const { data-tooltip-title={title} data-tooltip-desc={description} > - {label} + + {label} + {abbr && {abbr}} +
{ options.map((opt) => ( @@ -56,8 +61,23 @@ const { min-width: 3ch; font-weight: 700; font-family: var(--font-mono); - color: rgba(255, 255, 255, 0.4); + color: #fff; opacity: 0.7; + display: flex; + transition: all 0.2s; + } + + .tui-segment-label .abbr { + display: none; + } + + @media (max-width: 1400px) { + .tui-segment-label.has-abbr .full { + display: none; + } + .tui-segment-label.has-abbr .abbr { + display: inline; + } } .tui-segment-options { @@ -72,7 +92,8 @@ const { background: transparent; border: none; border-right: 1px solid rgba(255, 255, 255, 0.05); - color: rgba(255, 255, 255, 0.5); + color: #fff; + opacity: 0.6; font-family: inherit; font-size: inherit; padding: 4px 10px; @@ -87,24 +108,26 @@ const { } .tui-segment-option:hover { - color: #fff; - background: rgba(255, 255, 255, 0.05); + color: var(--accent-color); + opacity: 1; + background: color-mix(in srgb, var(--accent-color), transparent 95%); } .tui-segment-option.active { - background: rgba(255, 255, 255, 0.15); + background: var(--accent-color); color: #fff; - font-weight: 600; + font-weight: 700; + opacity: 1; } /* Hover the whole group */ .tui-segment:hover .tui-segment-label { opacity: 1; - color: rgba(255, 255, 255, 0.8); + color: var(--accent-color); } .tui-segment:hover .tui-segment-options { - border-color: rgba(255, 255, 255, 0.2); + border-color: var(--accent-color); } diff --git a/src/components/TuiSlider.astro b/src/components/TuiSlider.astro index e593816..f2f2a5e 100644 --- a/src/components/TuiSlider.astro +++ b/src/components/TuiSlider.astro @@ -2,6 +2,7 @@ interface Props { id: string; label: string; + abbr?: string; min?: number; max?: number; step?: number; @@ -13,6 +14,7 @@ interface Props { const { id, label, + abbr, min = 0, max = 5, step = 0.1, @@ -28,10 +30,18 @@ const segments = 12;
- {label} +
+ + {label} + {abbr && {abbr}} + + {value.toFixed(2)} +
@@ -56,26 +66,50 @@ const segments = 12; value={value} />
- {value.toFixed(2)}
@@ -166,6 +227,9 @@ const segments = 12; const valueDisplay = sliderContainer.querySelector( ".tui-slider-value", ) as HTMLElement; + const defaultValue = parseFloat( + sliderContainer.getAttribute("data-default-value") || "0", + ); if (!input || !track || !valueDisplay) return; @@ -196,6 +260,10 @@ const segments = 12; }); valueDisplay.textContent = val.toFixed(2); + + // Add modified class if value differs from default + const isModified = Math.abs(val - defaultValue) > 0.001; + sliderContainer.classList.toggle("modified", isModified); } input.addEventListener("input", updateVisual); diff --git a/src/components/TuiToggle.astro b/src/components/TuiToggle.astro index df23841..a18624c 100644 --- a/src/components/TuiToggle.astro +++ b/src/components/TuiToggle.astro @@ -2,6 +2,7 @@ interface Props { id: string; label: string; + abbr?: string; checked?: boolean; title?: string; description?: string; @@ -10,6 +11,7 @@ interface Props { const { id, label, + abbr, checked = false, title = "", description = "", @@ -24,7 +26,10 @@ const { data-tooltip-title={title} data-tooltip-desc={description} > - {label} + + {label} + {abbr && {abbr}} + diff --git a/src/pages/index.astro b/src/pages/index.astro index 19038e2..d778608 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -351,14 +351,22 @@ import ControlPanel from "../components/ControlPanel.astro"; @media (max-width: 1024px) { .split-layout { flex-direction: column; - overflow: hidden; /* Prevent body scroll, use inner scrolling */ - height: 100dvh; /* Dynamic viewport height */ + overflow: hidden; + height: 100dvh; } .ascii-workspace { - height: auto; - flex-grow: 1; /* Fill remaining space */ - overflow-y: auto; /* Allow scrolling inside workspace if needed */ + height: 0; /* Important for flex-grow to work reliably on all browsers */ + flex-grow: 1; + display: flex; + flex-direction: column; + min-height: 0; /* Allow shrinking */ + } + + .canvas-layer { + flex-grow: 1; + min-height: 0; + height: 0; /* Force flex-grow to determine height */ } } diff --git a/src/scripts/ascii-controller.ts b/src/scripts/ascii-controller.ts index 9ac3416..470c66f 100644 --- a/src/scripts/ascii-controller.ts +++ b/src/scripts/ascii-controller.ts @@ -322,7 +322,8 @@ export class AsciiController { const fontAspectRatio = 0.55; const marginRatio = 0.05; // Reduced margin for container fit - const screenW = parent.clientWidth; + let screenW = parent.clientWidth; + if (screenW <= 0) screenW = window.innerWidth || 1000; const availW = screenW * (1 - marginRatio); let widthCols = Math.floor(availW / 6); @@ -394,10 +395,18 @@ export class AsciiController { if (!parent) return; const fontAspectRatio = 0.55; - const gridAspect = (this.cachedGrid.widthCols * fontAspectRatio) / this.cachedGrid.heightRows; + // Safeguard against 0 height or NaNs + const heightRows = Math.max(1, Math.floor(this.cachedGrid.heightRows)); + const widthCols = Math.max(1, this.cachedGrid.widthCols); + const gridAspect = (widthCols * fontAspectRatio) / heightRows; + + let screenW = parent.clientWidth; + let screenH = parent.clientHeight; + + // Fallback for mobile initialization quirks where parent might be 0 initially + if (screenW <= 0) screenW = window.innerWidth; + if (screenH <= 0) screenH = window.innerHeight * 0.5; // Guessing half screen for workspace - const screenW = parent.clientWidth; - const screenH = parent.clientHeight; const maxW = screenW * 0.98; const maxH = screenH * 0.98; @@ -410,6 +419,12 @@ export class AsciiController { finalW = maxH * gridAspect; } + // Final safeguard against zero or NaN + if (!finalW || !finalH || isNaN(finalW) || isNaN(finalH)) { + finalW = 300; + finalH = 300 / gridAspect; + } + this.canvas.style.width = `${finalW}px`; this.canvas.style.height = `${finalH}px`; @@ -425,6 +440,12 @@ export class AsciiController { this.canvas.style.display = 'block'; this.canvas.style.opacity = '1'; this.requestRender('all'); + + // Insurance for mobile: trigger a second sizing/render after a short delay + // to catch cases where the layout might still be shifting (keyboard, address bar) + setTimeout(() => { + this.handleResize(); + }, 100); } // ============= Zoom & Touch ============= diff --git a/src/scripts/ui-bindings.ts b/src/scripts/ui-bindings.ts index 6a7bd50..04ebd39 100644 --- a/src/scripts/ui-bindings.ts +++ b/src/scripts/ui-bindings.ts @@ -29,6 +29,8 @@ export class UIBindings { private queue: ImageQueue; private loadNewImageFn: () => Promise; private isUpdatingUI = false; + private lastNextTime = 0; + private readonly NEXT_COOLDOWN = 1000; // 1 second cooldown // Event Handlers implementation references @@ -315,6 +317,11 @@ export class UIBindings { if (btnNext) { const handler = (e: Event) => { e.stopPropagation(); + + const now = Date.now(); + if (now - this.lastNextTime < this.NEXT_COOLDOWN) return; + this.lastNextTime = now; + this.loadNewImageFn(); }; this.buttonHandlers.set('btn-next', handler); @@ -365,6 +372,9 @@ export class UIBindings { switch (e.key.toLowerCase()) { case 'n': + const now = Date.now(); + if (now - this.lastNextTime < this.NEXT_COOLDOWN) break; + this.lastNextTime = now; this.loadNewImageFn(); break; case 'r': diff --git a/src/scripts/webgl-ascii.ts b/src/scripts/webgl-ascii.ts index 82f727c..8702820 100644 --- a/src/scripts/webgl-ascii.ts +++ b/src/scripts/webgl-ascii.ts @@ -1,5 +1,4 @@ - export interface RenderOptions { charSetContent: string; fontFamily?: string; @@ -60,8 +59,8 @@ export class WebGLAsciiRenderer { this.gl = gl; // Enable required extensions for advanced rendering - gl.getExtension('OES_standard_derivatives'); - gl.getExtension('EXT_shader_texture_lod'); + const hasDerivatives = !!gl.getExtension('OES_standard_derivatives'); + const hasLod = !!gl.getExtension('EXT_shader_texture_lod'); this.program = null; this.textures = {}; @@ -71,11 +70,11 @@ export class WebGLAsciiRenderer { this.lastImage = null; this.fontFamily = "'JetBrains Mono', monospace"; - this.init(); + this.init(hasDerivatives, hasLod); this.loadBlueNoiseTexture(); } - init() { + init(hasDerivatives: boolean, hasLod: boolean) { const gl = this.gl; // Vertex Shader @@ -91,8 +90,8 @@ export class WebGLAsciiRenderer { // Fragment Shader const fsSource = ` - #extension GL_OES_standard_derivatives : enable - #extension GL_EXT_shader_texture_lod : enable + ${hasDerivatives ? '#extension GL_OES_standard_derivatives : enable' : ''} + ${hasLod ? '#extension GL_EXT_shader_texture_lod : enable' : ''} precision mediump float; varying vec2 v_texCoord; @@ -100,11 +99,10 @@ export class WebGLAsciiRenderer { uniform sampler2D u_atlas; uniform sampler2D u_blueNoise; uniform float u_charCount; - uniform vec2 u_charSizeUV; // Size of one char in UV space (width/texWidth, height/texHeight) - uniform vec2 u_gridSize; // cols, rows - uniform vec2 u_texSize; // atlas size + uniform vec2 u_charSizeUV; + uniform vec2 u_gridSize; + uniform vec2 u_texSize; - // Adjustments uniform float u_exposure; uniform float u_contrast; uniform float u_saturation; @@ -112,8 +110,8 @@ export class WebGLAsciiRenderer { uniform bool u_invert; uniform bool u_color; uniform float u_overlayStrength; - uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny - uniform float u_dither; // Dither strength 0.0 - 1.0 + uniform int u_edgeMode; + uniform float u_dither; uniform bool u_denoise; uniform float u_sharpen; uniform float u_edgeThreshold; @@ -123,7 +121,6 @@ export class WebGLAsciiRenderer { uniform float u_vignette; uniform vec3 u_monoColor; - // Zoom & Magnifier uniform float u_zoom; uniform vec2 u_zoomCenter; uniform vec2 u_mousePos; @@ -132,65 +129,41 @@ export class WebGLAsciiRenderer { uniform bool u_showMagnifier; uniform float u_aspect; - // Blue Noise Dithering float blueNoise(vec2 pos) { - // Map screen coordinates to texture coordinates (64x64 texture) vec2 noiseUV = pos / 64.0; float noiseVal = texture2D(u_blueNoise, noiseUV).r; - // Shift range to -0.5 to 0.5 for dither offset return noiseVal - 0.5; } vec3 adjust(vec3 color) { - // Exposure color *= u_exposure; - - // Shadows / Highlights float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); - - // Shadows: lift dark areas if (u_shadows > 0.0) { float shadowFactor = (1.0 - luma) * u_shadows; color = color + (vec3(1.0) - color) * shadowFactor * 0.5; } - - // Highlights: dim bright areas if (u_highlights > 0.0) { float highlightFactor = luma * u_highlights; color = color * (1.0 - highlightFactor * 0.5); } - - // Contrast color = (color - 0.5) * u_contrast + 0.5; - - // Saturation luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); color = mix(vec3(luma), color, u_saturation); - - // Gamma color = pow(max(color, 0.0), vec3(u_gamma)); - return clamp(color, 0.0, 1.0); } - // Function to get average color from a cell using 5 samples (center + corners) vec3 getAverageColor(vec2 cellCenterUV, vec2 cellSize) { vec3 sum = vec3(0.0); - vec2 halfSize = cellSize * 0.25; // Sample halfway to the edge - - // Center + vec2 halfSize = cellSize * 0.25; sum += texture2D(u_image, cellCenterUV).rgb; - - // Corners sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, -halfSize.y)).rgb; sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, -halfSize.y)).rgb; sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, halfSize.y)).rgb; sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, halfSize.y)).rgb; - return sum / 5.0; } - // Sobel Filter - returns gradient magnitude and direction (approx) vec2 sobelFilter(vec2 uv, vec2 cellSize) { vec3 t = texture2D(u_image, uv + vec2(0.0, -cellSize.y)).rgb; vec3 b = texture2D(u_image, uv + vec2(0.0, cellSize.y)).rgb; @@ -200,8 +173,6 @@ export class WebGLAsciiRenderer { vec3 tr = texture2D(u_image, uv + vec2(cellSize.x, -cellSize.y)).rgb; vec3 bl = texture2D(u_image, uv + vec2(-cellSize.x, cellSize.y)).rgb; vec3 br = texture2D(u_image, uv + vec2(cellSize.x, cellSize.y)).rgb; - - // Convert to luma float lt = dot(t, vec3(0.299, 0.587, 0.114)); float lb = dot(b, vec3(0.299, 0.587, 0.114)); float ll = dot(l, vec3(0.299, 0.587, 0.114)); @@ -210,122 +181,82 @@ export class WebGLAsciiRenderer { float ltr = dot(tr, vec3(0.299, 0.587, 0.114)); float lbl = dot(bl, vec3(0.299, 0.587, 0.114)); float lbr = dot(br, vec3(0.299, 0.587, 0.114)); - - // Sobel kernels - // Gx: -1 0 1 - // -2 0 2 - // -1 0 1 float gx = (ltr + 2.0*lr + lbr) - (ltl + 2.0*ll + lbl); - - // Gy: -1 -2 -1 - // 0 0 0 - // 1 2 1 float gy = (lbl + 2.0*lb + lbr) - (ltl + 2.0*lt + ltr); - float mag = sqrt(gx*gx + gy*gy); return vec2(mag, atan(gy, gx)); } void main() { vec2 uv = v_texCoord; - - // Apply global zoom uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter; - // Magnifier logic vec2 diff = (v_texCoord - u_mousePos); diff.x *= u_aspect; float dist = length(diff); bool inMagnifier = u_showMagnifier && dist < u_magnifierRadius; if (inMagnifier) { - // Zoom towards mouse position inside the magnifier uv = (v_texCoord - u_mousePos) / u_magnifierZoom + u_mousePos; - // Also account for the global zoom background uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter; } - // Calculate which cell we are in vec2 cellCoords = floor(uv * u_gridSize); vec2 uvInCell = fract(uv * u_gridSize); - - // Sample image at the center of the cell vec2 cellSize = 1.0 / u_gridSize; vec2 sampleUV = (cellCoords + 0.5) * cellSize; - // Out of bounds check for zoomed UV if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) { discard; } vec3 color; - - // Denoise: 3x3 box blur (applied to the base sampling if enabled) if (u_denoise) { color = getAverageColor(sampleUV, cellSize * 2.0); } else { color = getAverageColor(sampleUV, cellSize); } - // Sharpening if (u_sharpen > 0.0) { vec3 blurred = getAverageColor(sampleUV, cellSize * 2.0); color = color + (color - blurred) * u_sharpen; } - // Edge Detection Logic if (u_edgeMode == 1) { - // Simple Laplacian-like vec2 texel = cellSize; vec3 center = color; vec3 top = getAverageColor(sampleUV + vec2(0.0, -texel.y), cellSize); vec3 bottom = getAverageColor(sampleUV + vec2(0.0, texel.y), cellSize); vec3 left = getAverageColor(sampleUV + vec2(-texel.x, 0.0), cellSize); vec3 right = getAverageColor(sampleUV + vec2(texel.x, 0.0), cellSize); - vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right); float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722)); - - // Use Threshold if (edgeLum > u_edgeThreshold * 0.1) { color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5); } - } else if (u_edgeMode == 2) { - // Sobel Gradient vec2 sobel = sobelFilter(sampleUV, cellSize); float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0); - if (edgeStr > u_edgeThreshold * 0.2) { color = mix(color, vec3(0.0), edgeStr * 0.8); } - } else if (u_edgeMode == 3) { - // "Canny-like" (Sobel + gradient suppression) vec2 sobel = sobelFilter(sampleUV, cellSize); float mag = sobel.x; float angle = sobel.y; - - // Non-maximum suppression (simplified) vec2 dir = vec2(cos(angle), sin(angle)) * cellSize; vec2 s1 = sobelFilter(sampleUV + dir, cellSize); vec2 s2 = sobelFilter(sampleUV - dir, cellSize); - - // Use edge threshold - if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) { // scaled threshold + if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) { mag = 0.0; } else { - mag = 1.0; // Strong edge + mag = 1.0; } - - // Apply strong crisp edges color = mix(color, vec3(0.0), mag); } - // Apply adjustments (Contrast, etc.) color = adjust(color); - // Overlay blend-like effect (boost mid-contrast) if (u_overlayStrength > 0.0) { vec3 overlay = color; vec3 result; @@ -337,15 +268,10 @@ export class WebGLAsciiRenderer { color = mix(color, result, u_overlayStrength); } - // Calculate luminance float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); - // Apply Blue Noise dithering before character mapping if (u_dither > 0.0) { - // Use cell coordinates for stable dithering patterns float noise = blueNoise(cellCoords); - - // Scale noise by dither strength and 1/charCount luma = luma + noise * (1.0 / u_charCount) * u_dither; luma = clamp(luma, 0.0, 1.0); } @@ -354,53 +280,42 @@ export class WebGLAsciiRenderer { luma = 1.0 - luma; } - // Map luma to character index float charIndex = floor(luma * (u_charCount - 1.0) + 0.5); - // Sample character atlas - // Use u_charSizeUV to scale, instead of just 1.0/u_charCount - // x = charIndex * charWidthUV + uvInCell.x * charWidthUV vec2 atlasUV = vec2( (charIndex + uvInCell.x) * u_charSizeUV.x, uvInCell.y * u_charSizeUV.y ); - // --- MIPMAP FIX --- - // Use zoomed 'uv' for derivatives to handle zoom correctly. - // Multiply by 0.5 to bias towards sharper mipmaps (LOD bias). - vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5; - vec2 gradY = dFdy(uv) * u_gridSize * u_charSizeUV * 0.5; + float charAlpha; + ${hasDerivatives && hasLod ? ` + vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5; + vec2 gradY = dFdy(uv) * u_gridSize * u_charSizeUV * 0.5; + charAlpha = texture2DGradEXT(u_atlas, atlasUV, gradX, gradY).r; + ` : ` + charAlpha = texture2D(u_atlas, atlasUV).r; + `} - // Use texture2DGradEXT to sample with explicit gradients, resolving cell boundary artifacts - float charAlpha = texture2DGradEXT(u_atlas, atlasUV, gradX, gradY).r; - - // Loup border effect if (u_showMagnifier) { float edgeWidth = 0.005; if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) { charAlpha = 1.0; - color = vec3(1.0, 1.0, 1.0); // White border for the loupe + color = vec3(1.0, 1.0, 1.0); } } - // Vignette if (u_vignette > 0.0) { - float dist = distance(uv, vec2(0.5)); - // Smoothsoft vignette - float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, dist); + float d = distance(uv, vec2(0.5)); + float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, d); charAlpha *= vig; } - // Scanlines if (u_scanlines > 0.0) { - // Sine wave based on grid rows float scan = 0.5 + 0.5 * sin(uv.y * u_gridSize.y * 3.14159 * 2.0); - // Blend scanline effect based on strength float scanEffect = mix(1.0, scan, u_scanlines * 0.5); charAlpha *= scanEffect; } - // Mono Color vec3 finalColor; if (u_color) { finalColor = color; @@ -494,28 +409,24 @@ export class WebGLAsciiRenderer { this.fontFamily = fontName; const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) return; - const fontSize = 32; // Higher resolution for atlas - // Add padding to prevent bleeding + const fontSize = 32; const padding = 4; ctx.font = `${fontSize}px ${fontName}`; - // Measure first char to get dimensions const metrics = ctx.measureText('W'); const charContentWidth = Math.ceil(metrics.width); const charContentHeight = Math.ceil(fontSize * 1.2); - // Full cell size including padding const charWidth = charContentWidth + padding * 2; const charHeight = charContentHeight + padding * 2; const neededWidth = charWidth * charSet.length; const neededHeight = charHeight; - // Calculate Next Power of Two const nextPowerOfTwo = (v: number) => Math.pow(2, Math.ceil(Math.log(v) / Math.log(2))); const texWidth = nextPowerOfTwo(neededWidth); const texHeight = nextPowerOfTwo(neededHeight); @@ -529,9 +440,6 @@ export class WebGLAsciiRenderer { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < charSet.length; i++) { - // Draw character centered in its padded cell - // x position: start of cell (i * charWidth) + padding - // y position: padding ctx.fillText(charSet[i], i * charWidth + padding, padding); } @@ -545,7 +453,6 @@ export class WebGLAsciiRenderer { gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); - // Use Mipmaps for smoother downscaling (fixes shimmering/aliasing) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -572,13 +479,12 @@ export class WebGLAsciiRenderer { const gl = this.gl; const u = this.uniformLocations; + if (!this.program) return; gl.useProgram(this.program); - // Update Atlas if needed (expensive check inside) this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace'); if (this.charAtlas) { gl.uniform1f(u['u_charCount'], this.charAtlas.count); - // Pass the normalized size of one character cell for UV mapping gl.uniform2f(u['u_charSizeUV'], this.charAtlas.charWidth / this.charAtlas.width, this.charAtlas.charHeight / this.charAtlas.height @@ -597,20 +503,18 @@ export class WebGLAsciiRenderer { gl.uniform1f(u['u_dither'], options.dither || 0.0); gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0); gl.uniform1f(u['u_sharpen'], options.sharpen || 0.0); - gl.uniform1f(u['u_edgeThreshold'], options.edgeThreshold || 0.5); // Default to mid + gl.uniform1f(u['u_edgeThreshold'], options.edgeThreshold || 0.5); gl.uniform1f(u['u_shadows'], options.shadows || 0.0); gl.uniform1f(u['u_highlights'], options.highlights || 0.0); gl.uniform1f(u['u_scanlines'], options.scanlines || 0.0); gl.uniform1f(u['u_vignette'], options.vignette || 0.0); - // Parse hex color const hex = options.monoColor || '#ffffff'; const r = parseInt(hex.slice(1, 3), 16) / 255.0; const g = parseInt(hex.slice(3, 5), 16) / 255.0; const b = parseInt(hex.slice(5, 7), 16) / 255.0; gl.uniform3f(u['u_monoColor'], r, g, b); - // Zoom & Magnifier gl.uniform1f(u['u_zoom'], options.zoom || 1.0); gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5); gl.uniform2f(u['u_mousePos'], options.mousePos?.x ?? -1.0, options.mousePos?.y ?? -1.0); @@ -624,7 +528,6 @@ export class WebGLAsciiRenderer { const gl = this.gl; const texture = gl.createTexture(); if (!texture) return; - this.textures.blueNoise = texture; const image = new Image(); @@ -636,27 +539,16 @@ export class WebGLAsciiRenderer { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); - this.requestRender(); }; } - // Helper to trigger a redraw if we have a controller reference, otherwise just rely on next loop - private requestRender() { - // Since we don't have a direct reference to the controller here, - // and we are in a render loop managed by the controller, - // the texture will just appear on the next frame. - } - updateTexture(image: HTMLImageElement) { if (this.lastImage === image && this.textures.image) return; - const gl = this.gl; - if (this.textures.image) gl.deleteTexture(this.textures.image); const texture = gl.createTexture(); if (!texture) throw new Error('Failed to create texture'); this.textures.image = texture; - gl.bindTexture(gl.TEXTURE_2D, this.textures.image); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); @@ -669,16 +561,13 @@ export class WebGLAsciiRenderer { draw() { const gl = this.gl; const program = this.program; - if (!program || !this.textures.image || !this.textures.atlas || !this.buffers.position || !this.buffers.texCoord) return; gl.useProgram(program); - gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); - // Attributes const posLoc = gl.getAttribLocation(program, 'a_position'); gl.enableVertexAttribArray(posLoc); gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position); @@ -689,7 +578,6 @@ export class WebGLAsciiRenderer { gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord); gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0); - // Bind Textures const u = this.uniformLocations; gl.uniform1i(u['u_image'], 0); gl.activeTexture(gl.TEXTURE0); @@ -715,32 +603,14 @@ export class WebGLAsciiRenderer { this.draw(); } - /** - * Dispose of all WebGL resources. - * Call this when the renderer is no longer needed. - */ dispose(): void { const gl = this.gl; - - if (this.textures.image) { - gl.deleteTexture(this.textures.image); - } - if (this.textures.atlas) { - gl.deleteTexture(this.textures.atlas); - } - if (this.textures.blueNoise) { - gl.deleteTexture(this.textures.blueNoise); - } - if (this.buffers.position) { - gl.deleteBuffer(this.buffers.position); - } - if (this.buffers.texCoord) { - gl.deleteBuffer(this.buffers.texCoord); - } - if (this.program) { - gl.deleteProgram(this.program); - } - + if (this.textures.image) gl.deleteTexture(this.textures.image); + if (this.textures.atlas) gl.deleteTexture(this.textures.atlas); + if (this.textures.blueNoise) gl.deleteTexture(this.textures.blueNoise); + if (this.buffers.position) gl.deleteBuffer(this.buffers.position); + if (this.buffers.texCoord) gl.deleteBuffer(this.buffers.texCoord); + if (this.program) gl.deleteProgram(this.program); this.textures = {}; this.buffers = {}; this.program = null; @@ -748,29 +618,21 @@ export class WebGLAsciiRenderer { this.lastImage = null; } - // Kept for backward compatibility or specialized updates updateMagnifier(options: MagnifierOptions) { const gl = this.gl; const program = this.program; - if (!program) return; - gl.useProgram(program); - - // Only update magnifier-related uniforms (using cached locations) const u = this.uniformLocations; const mousePos = options.mousePos ?? { x: -1, y: -1 }; gl.uniform2f(u['u_mousePos'], mousePos.x, mousePos.y); gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.03); gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0); gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0); - if (options.zoom !== undefined) { gl.uniform1f(u['u_zoom'], options.zoom || 1.0); gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5); } - - // We can just call draw here as it's lightweight this.draw(); } } diff --git a/src/styles/global.css b/src/styles/global.css index d951886..ec5fcb4 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,6 +1,8 @@ :root { --bg-color: #000000; --text-color: #FFFFFF; + --accent-color: #FF6700; + /* Safety Orange */ --font-mono: 'JetBrains Mono', monospace; } @@ -28,7 +30,7 @@ button { button:hover { opacity: 1; - background: rgba(255, 103, 0, 0.1); + background: color-mix(in srgb, var(--accent-color), transparent 90%); } a {