From 72ae22cac7357f17d67cc7526e2fa0f47ed6112f Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Tue, 25 Aug 2026 12:40:08 +0700 Subject: [PATCH 1/2] refactor(renderer): centralize card shadow constants in layout Single source of truth for shadow blur/offset shared by the canvas and SVG pipelines. SVG derives dy/stdDeviation from the same values, so the two renderers cannot drift apart. --- crates/renderer/src/canvas.rs | 12 ++++++------ crates/renderer/src/layout.rs | 15 +++++++++++++++ crates/renderer/src/svg.rs | 20 ++++++++++++-------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/crates/renderer/src/canvas.rs b/crates/renderer/src/canvas.rs index 8e95c57..b33ab4a 100644 --- a/crates/renderer/src/canvas.rs +++ b/crates/renderer/src/canvas.rs @@ -12,8 +12,8 @@ use wasm_bindgen::JsCast; use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement}; use crate::layout::{ - compute_layout, split_tokens_into_lines, Layout, TRAFFIC_LIGHT_OFFSET_X, TRAFFIC_LIGHT_PITCH, - TRAFFIC_LIGHT_RADIUS, + compute_layout, split_tokens_into_lines, Layout, CARD_SHADOW_BLUR, CARD_SHADOW_OFFSET_Y, + TRAFFIC_LIGHT_OFFSET_X, TRAFFIC_LIGHT_PITCH, TRAFFIC_LIGHT_RADIUS, }; /// Conservative maximum canvas dimension in *device* pixels. Safari caps @@ -258,8 +258,8 @@ pub fn draw_prepared( )?; if options.window_frame { ctx.set_shadow_color("rgba(0, 0, 0, 0.35)"); - ctx.set_shadow_blur(20.0); - ctx.set_shadow_offset_y(10.0); + ctx.set_shadow_blur(CARD_SHADOW_BLUR); + ctx.set_shadow_offset_y(CARD_SHADOW_OFFSET_Y); } ctx.set_fill_style_str(&palette.background.to_css()); ctx.fill(); @@ -558,8 +558,8 @@ fn draw_panel( )?; if options.window_frame { ctx.set_shadow_color("rgba(0, 0, 0, 0.35)"); - ctx.set_shadow_blur(20.0); - ctx.set_shadow_offset_y(10.0); + ctx.set_shadow_blur(CARD_SHADOW_BLUR); + ctx.set_shadow_offset_y(CARD_SHADOW_OFFSET_Y); } ctx.set_fill_style_str(&palette.background.to_css()); ctx.fill(); diff --git a/crates/renderer/src/layout.rs b/crates/renderer/src/layout.rs index bc5b00c..607be03 100644 --- a/crates/renderer/src/layout.rs +++ b/crates/renderer/src/layout.rs @@ -21,6 +21,21 @@ pub const TRAFFIC_LIGHT_PITCH: f64 = 22.0; /// X offset of the first traffic-light dot center from the card edge. pub const TRAFFIC_LIGHT_OFFSET_X: f64 = 22.0; +/// Canvas2D `shadowBlur` of the code-card drop shadow (logical px). +pub const CARD_SHADOW_BLUR: f64 = 20.0; + +/// Canvas2D `shadowOffsetY` of the code-card drop shadow (logical px). +pub const CARD_SHADOW_OFFSET_Y: f64 = 10.0; + +/// Conservative paint extent of the card shadow beyond the card edge. +/// +/// `shadowBlur` spreads the shadow both ways around the shape, so the +/// painted overflow is at most `blur + |offset|` — glyph-style metrics (e.g. +/// `measureText`) never cover it. [`compute_layout`] clamps the canvas +/// padding to at least this when the window frame is on, so the shadow never +/// clips at the canvas edge. +pub const CARD_SHADOW_SAFE_MARGIN: f64 = CARD_SHADOW_BLUR + CARD_SHADOW_OFFSET_Y; + /// Gap between the line-number gutter and the code, in character cells. pub const GUTTER_GAP_CELLS: f64 = 1.5; diff --git a/crates/renderer/src/svg.rs b/crates/renderer/src/svg.rs index e99ae86..b85bcd9 100644 --- a/crates/renderer/src/svg.rs +++ b/crates/renderer/src/svg.rs @@ -7,8 +7,8 @@ use codeframe_models::{Background, ExportOptions, FontStyle, GradientDir, ThemePalette, Token}; use crate::layout::{ - compute_layout, split_tokens_into_lines, Layout, TRAFFIC_LIGHT_OFFSET_X, TRAFFIC_LIGHT_PITCH, - TRAFFIC_LIGHT_RADIUS, + compute_layout, split_tokens_into_lines, Layout, CARD_SHADOW_BLUR, CARD_SHADOW_OFFSET_Y, + TRAFFIC_LIGHT_OFFSET_X, TRAFFIC_LIGHT_PITCH, TRAFFIC_LIGHT_RADIUS, }; /// macOS traffic-light colors (close, minimize, zoom). @@ -94,11 +94,13 @@ pub fn render_svg( svg.push_str(""); // Drop-shadow filter for the card. if options.window_frame { - svg.push_str( + svg.push_str(&format!( r#" - + "#, - ); + dy = CARD_SHADOW_OFFSET_Y, + sd = CARD_SHADOW_BLUR / 2.0, + )); } // Background gradient defs. match &options.background { @@ -293,11 +295,13 @@ pub fn render_split_svg( // Defs. svg.push_str(""); if options.window_frame { - svg.push_str( + svg.push_str(&format!( r#" - + "#, - ); + dy = CARD_SHADOW_OFFSET_Y, + sd = CARD_SHADOW_BLUR / 2.0, + )); } match &options.background { Background::LinearGradient { colors, dir } if colors.len() >= 2 => { From 1df7d359e70109bf9d59fe9acd3882ccd90a490a Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Tue, 25 Aug 2026 12:43:58 +0700 Subject: [PATCH 2/2] fix(renderer): clamp padding so the card shadow never clips The card drop shadow paints up to blur + offsetY beyond the card edge, but text metrics (measureText) are blind to painted overflow. compute_layout now clamps the effective padding to CARD_SHADOW_SAFE_MARGIN when the window frame is on, so a small padding (UI minimum is 16px, shadow extent is 30px) can no longer cut the shadow off at the canvas edge. The split-screen divider now follows the clamped card_y instead of raw options.padding. --- crates/renderer/src/canvas.rs | 4 +- crates/renderer/src/layout.rs | 75 +++++++++++++++++++++++++++++++---- crates/renderer/src/svg.rs | 4 +- 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/crates/renderer/src/canvas.rs b/crates/renderer/src/canvas.rs index b33ab4a..c6720f9 100644 --- a/crates/renderer/src/canvas.rs +++ b/crates/renderer/src/canvas.rs @@ -520,9 +520,9 @@ pub fn draw_split_prepared( ctx.set_fill_style_str("rgba(128, 128, 128, 0.3)"); ctx.fill_rect( divider_x, - options.padding, + split.left.card_y, 1.0, - split.canvas_height - 2.0 * options.padding, + split.canvas_height - 2.0 * split.left.card_y, ); // 4. Right panel (offset by left width + gap). diff --git a/crates/renderer/src/layout.rs b/crates/renderer/src/layout.rs index 607be03..dd43f14 100644 --- a/crates/renderer/src/layout.rs +++ b/crates/renderer/src/layout.rs @@ -145,21 +145,29 @@ pub fn compute_layout( } else { 0.0 }; + // The card drop shadow paints up to `CARD_SHADOW_SAFE_MARGIN` px beyond the + // card edge (painted overflow is invisible to text metrics), so clamp the + // padding to keep it inside the canvas. + let padding = if options.window_frame { + options.padding.max(CARD_SHADOW_SAFE_MARGIN) + } else { + options.padding + }; let card_width = gutter + max_line_width_px + 2.0 * INNER_PADDING; let card_height = header_height + code_height + 2.0 * INNER_PADDING; Layout { - canvas_width: options.padding + card_width + options.padding, - canvas_height: options.padding + card_height + options.padding, - card_x: options.padding, - card_y: options.padding, + canvas_width: padding + card_width + padding, + canvas_height: padding + card_height + padding, + card_x: padding, + card_y: padding, card_width, card_height, header_height, - code_origin_x: options.padding + INNER_PADDING + gutter, - code_origin_y: options.padding + header_height + INNER_PADDING, - gutter_right_x: options.padding + INNER_PADDING + gutter - 0.5 * char_width, + code_origin_x: padding + INNER_PADDING + gutter, + code_origin_y: padding + header_height + INNER_PADDING, + gutter_right_x: padding + INNER_PADDING + gutter - 0.5 * char_width, line_height_px, line_count, } @@ -276,4 +284,57 @@ mod tests { options.padding + INNER_PADDING + gutter - 0.5 * char_width ); } + + #[test] + fn frame_raises_padding_below_shadow_margin() { + let options = ExportOptions { + padding: 16.0, + window_frame: true, + line_numbers: false, + ..Default::default() + }; + let layout = compute_layout(&options, 1, 50.0, 8.0); + assert_eq!(layout.card_x, CARD_SHADOW_SAFE_MARGIN); + assert_eq!(layout.card_y, CARD_SHADOW_SAFE_MARGIN); + assert_eq!( + layout.canvas_width, + CARD_SHADOW_SAFE_MARGIN + layout.card_width + CARD_SHADOW_SAFE_MARGIN + ); + assert_eq!( + layout.canvas_height, + CARD_SHADOW_SAFE_MARGIN + layout.card_height + CARD_SHADOW_SAFE_MARGIN + ); + } + + #[test] + fn frame_keeps_padding_at_or_above_shadow_margin() { + let at_margin = ExportOptions { + padding: CARD_SHADOW_SAFE_MARGIN, + window_frame: true, + ..Default::default() + }; + assert_eq!( + compute_layout(&at_margin, 1, 50.0, 8.0).card_x, + CARD_SHADOW_SAFE_MARGIN + ); + let above_margin = ExportOptions { + padding: 48.0, + window_frame: true, + ..Default::default() + }; + assert_eq!(compute_layout(&above_margin, 1, 50.0, 8.0).card_x, 48.0); + } + + #[test] + fn no_frame_leaves_padding_untouched() { + let options = ExportOptions { + padding: 16.0, + window_frame: false, + line_numbers: false, + ..Default::default() + }; + let layout = compute_layout(&options, 1, 50.0, 8.0); + assert_eq!(layout.card_x, 16.0); + assert_eq!(layout.card_y, 16.0); + } } diff --git a/crates/renderer/src/svg.rs b/crates/renderer/src/svg.rs index b85bcd9..fc821e9 100644 --- a/crates/renderer/src/svg.rs +++ b/crates/renderer/src/svg.rs @@ -381,8 +381,8 @@ pub fn render_split_svg( let divider_x = layout_left.canvas_width + SPLIT_GAP / 2.0; svg.push_str(&format!( "", - y = options.padding, - h = total_h - 2.0 * options.padding, + y = layout_left.card_y, + h = total_h - 2.0 * layout_left.card_y, )); // Right panel.