From 58b839a6f5b523d930e3a113a083b811b6a730b2 Mon Sep 17 00:00:00 2001 From: Hyunwoo Park Date: Sat, 9 May 2026 12:00:56 +0000 Subject: [PATCH 1/4] Task #536 P7: native Skia form control static replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PushButton: 둥근 사각형 + 중앙 정렬 캡션 - CheckBox: 체크박스 + 체크마크(value≠0) + 캡션 - RadioButton: 원형 + 내부 점(value≠0) + 캡션 - ComboBox: 입력 필드 + 드롭다운 화살표 삼각형 + 텍스트 - Edit: 입력 필드 + 텍스트 - CSS 색상 (#rrggbb) 파싱 → Skia Color 변환 - 기존 draw_placeholder 호출을 form_type별 정적 드로잉으로 교체 Part of #536 --- src/renderer/skia/renderer.rs | 229 +++++++++++++++++++++++++++++++++- 1 file changed, 228 insertions(+), 1 deletion(-) diff --git a/src/renderer/skia/renderer.rs b/src/renderer/skia/renderer.rs index e31a8d887..70a342815 100644 --- a/src/renderer/skia/renderer.rs +++ b/src/renderer/skia/renderer.rs @@ -755,7 +755,7 @@ impl SkiaLayerRenderer { canvas.restore(); } PaintOp::FormObject { bbox, form } => { - draw_placeholder(*bbox, form.caption.as_str()); + draw_form_control(canvas, *bbox, form); } PaintOp::Placeholder { bbox, placeholder } => { draw_placeholder(*bbox, placeholder.label.as_str()); @@ -778,6 +778,233 @@ impl LayerRasterRenderer for SkiaLayerRenderer { } } +fn draw_form_control( + canvas: &Canvas, + bbox: crate::renderer::render_tree::BoundingBox, + form: &crate::renderer::render_tree::FormObjectNode, +) { + use crate::model::control::FormType; + + if bbox.width <= 0.0 || bbox.height <= 0.0 { + return; + } + + let x = bbox.x as f32; + let y = bbox.y as f32; + let w = bbox.width as f32; + let h = bbox.height as f32; + let rect = Rect::from_xywh(x, y, w, h); + + let bg_color = parse_css_color(&form.back_color).unwrap_or(Color::from_rgb(240, 240, 240)); + let fg_color = parse_css_color(&form.fore_color).unwrap_or(Color::from_rgb(0, 0, 0)); + let border_color = Color::from_rgb(160, 160, 160); + + match form.form_type { + FormType::PushButton => { + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_style(paint::Style::Fill); + fill.set_color(bg_color); + let rrect = RRect::new_rect_xy(rect, 3.0, 3.0); + canvas.draw_rrect(rrect, &fill); + + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(paint::Style::Stroke); + stroke.set_stroke_width(1.0); + stroke.set_color(border_color); + canvas.draw_rrect(rrect, &stroke); + + let label = if form.caption.is_empty() { &form.name } else { &form.caption }; + if !label.is_empty() { + let mut font = Font::default(); + font.set_size((h * 0.45).clamp(8.0, 14.0)); + let mut tp = Paint::default(); + tp.set_anti_alias(true); + tp.set_color(fg_color); + let text_w = font.measure_str(label, Some(&tp)).0; + let tx = x + (w - text_w) / 2.0; + let ty = y + h / 2.0 + font.size() * 0.35; + canvas.draw_str(label, (tx, ty), &font, &tp); + } + } + FormType::CheckBox => { + let box_size = h.min(w).min(14.0); + let bx = x + 2.0; + let by = y + (h - box_size) / 2.0; + let box_rect = Rect::from_xywh(bx, by, box_size, box_size); + + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_style(paint::Style::Fill); + fill.set_color(Color::WHITE); + canvas.draw_rect(box_rect, &fill); + + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(paint::Style::Stroke); + stroke.set_stroke_width(1.0); + stroke.set_color(border_color); + canvas.draw_rect(box_rect, &stroke); + + if form.value != 0 { + let mut check = Paint::default(); + check.set_anti_alias(true); + check.set_style(paint::Style::Stroke); + check.set_stroke_width(2.0); + check.set_color(fg_color); + check.set_stroke_cap(paint::Cap::Round); + let cx = bx + box_size * 0.2; + let cy = by + box_size * 0.55; + let mx = bx + box_size * 0.4; + let my = by + box_size * 0.75; + let ex = bx + box_size * 0.8; + let ey = by + box_size * 0.25; + let mut path = skia_safe::Path::new(); + path.move_to((cx, cy)); + path.line_to((mx, my)); + path.line_to((ex, ey)); + canvas.draw_path(&path, &check); + } + + if !form.caption.is_empty() { + let mut font = Font::default(); + font.set_size((h * 0.6).clamp(8.0, 13.0)); + let mut tp = Paint::default(); + tp.set_anti_alias(true); + tp.set_color(fg_color); + let tx = bx + box_size + 4.0; + let ty = y + h / 2.0 + font.size() * 0.35; + canvas.draw_str(&form.caption, (tx, ty), &font, &tp); + } + } + FormType::RadioButton => { + let r = h.min(w).min(14.0) / 2.0; + let cx = x + 2.0 + r; + let cy = y + h / 2.0; + + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_style(paint::Style::Fill); + fill.set_color(Color::WHITE); + canvas.draw_circle((cx, cy), r, &fill); + + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(paint::Style::Stroke); + stroke.set_stroke_width(1.0); + stroke.set_color(border_color); + canvas.draw_circle((cx, cy), r, &stroke); + + if form.value != 0 { + let mut dot = Paint::default(); + dot.set_anti_alias(true); + dot.set_style(paint::Style::Fill); + dot.set_color(fg_color); + canvas.draw_circle((cx, cy), r * 0.5, &dot); + } + + if !form.caption.is_empty() { + let mut font = Font::default(); + font.set_size((h * 0.6).clamp(8.0, 13.0)); + let mut tp = Paint::default(); + tp.set_anti_alias(true); + tp.set_color(fg_color); + let tx = cx + r + 4.0; + let ty = y + h / 2.0 + font.size() * 0.35; + canvas.draw_str(&form.caption, (tx, ty), &font, &tp); + } + } + FormType::ComboBox => { + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_style(paint::Style::Fill); + fill.set_color(Color::WHITE); + canvas.draw_rect(rect, &fill); + + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(paint::Style::Stroke); + stroke.set_stroke_width(1.0); + stroke.set_color(border_color); + canvas.draw_rect(rect, &stroke); + + // 드롭다운 화살표 영역 + let arrow_w = h.min(20.0); + let ax = x + w - arrow_w; + let arrow_rect = Rect::from_xywh(ax, y, arrow_w, h); + let mut abg = Paint::default(); + abg.set_anti_alias(true); + abg.set_style(paint::Style::Fill); + abg.set_color(bg_color); + canvas.draw_rect(arrow_rect, &abg); + canvas.draw_line((ax, y), (ax, y + h), &stroke); + + // 화살표 삼각형 + let mut arrow = Paint::default(); + arrow.set_anti_alias(true); + arrow.set_style(paint::Style::Fill); + arrow.set_color(Color::from_rgb(80, 80, 80)); + let acx = ax + arrow_w / 2.0; + let acy = y + h / 2.0; + let as_ = (arrow_w * 0.25).min(5.0); + let mut path = skia_safe::Path::new(); + path.move_to((acx - as_, acy - as_ * 0.5)); + path.line_to((acx + as_, acy - as_ * 0.5)); + path.line_to((acx, acy + as_ * 0.5)); + path.close(); + canvas.draw_path(&path, &arrow); + + let display = if form.text.is_empty() { &form.caption } else { &form.text }; + if !display.is_empty() { + let mut font = Font::default(); + font.set_size((h * 0.55).clamp(8.0, 13.0)); + let mut tp = Paint::default(); + tp.set_anti_alias(true); + tp.set_color(fg_color); + let tx = x + 4.0; + let ty = y + h / 2.0 + font.size() * 0.35; + canvas.draw_str(display, (tx, ty), &font, &tp); + } + } + FormType::Edit => { + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_style(paint::Style::Fill); + fill.set_color(Color::WHITE); + canvas.draw_rect(rect, &fill); + + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(paint::Style::Stroke); + stroke.set_stroke_width(1.0); + stroke.set_color(border_color); + canvas.draw_rect(rect, &stroke); + + let display = if form.text.is_empty() { &form.caption } else { &form.text }; + if !display.is_empty() { + let mut font = Font::default(); + font.set_size((h * 0.55).clamp(8.0, 13.0)); + let mut tp = Paint::default(); + tp.set_anti_alias(true); + tp.set_color(fg_color); + let tx = x + 4.0; + let ty = y + h / 2.0 + font.size() * 0.35; + canvas.draw_str(display, (tx, ty), &font, &tp); + } + } + } +} + +fn parse_css_color(s: &str) -> Option { + let s = s.trim().trim_start_matches('#'); + if s.len() != 6 { return None; } + let r = u8::from_str_radix(&s[0..2], 16).ok()?; + let g = u8::from_str_radix(&s[2..4], 16).ok()?; + let b = u8::from_str_radix(&s[4..6], 16).ok()?; + Some(Color::from_rgb(r, g, b)) +} + fn colorref_to_skia(color: ColorRef, alpha_scale: f32) -> Color { let b = ((color >> 16) & 0xFF) as u8; let g = ((color >> 8) & 0xFF) as u8; From 2889d9f4880d342f5492d46e044d6978edf0a701 Mon Sep 17 00:00:00 2001 From: Hyunwoo Park Date: Sat, 9 May 2026 12:16:44 +0000 Subject: [PATCH 2/4] =?UTF-8?q?Copilot=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81:=20RRect=20import,=20bg=5Fcolor=20=EC=9D=BC=EA=B4=80?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9,=20ComboBox/Edit=20text-only=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/skia/renderer.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/renderer/skia/renderer.rs b/src/renderer/skia/renderer.rs index 70a342815..3bd5f5126 100644 --- a/src/renderer/skia/renderer.rs +++ b/src/renderer/skia/renderer.rs @@ -1,6 +1,6 @@ use skia_safe::{ font, paint, surfaces, Canvas, Color, EncodedImageFormat, Font, FontMgr, FontStyle, Paint, - PathBuilder, PathEffect, Rect, Typeface, + PathBuilder, PathEffect, RRect, Rect, Typeface, }; use std::collections::HashMap; @@ -837,7 +837,7 @@ fn draw_form_control( let mut fill = Paint::default(); fill.set_anti_alias(true); fill.set_style(paint::Style::Fill); - fill.set_color(Color::WHITE); + fill.set_color(bg_color); canvas.draw_rect(box_rect, &fill); let mut stroke = Paint::default(); @@ -886,7 +886,7 @@ fn draw_form_control( let mut fill = Paint::default(); fill.set_anti_alias(true); fill.set_style(paint::Style::Fill); - fill.set_color(Color::WHITE); + fill.set_color(bg_color); canvas.draw_circle((cx, cy), r, &fill); let mut stroke = Paint::default(); @@ -919,7 +919,7 @@ fn draw_form_control( let mut fill = Paint::default(); fill.set_anti_alias(true); fill.set_style(paint::Style::Fill); - fill.set_color(Color::WHITE); + fill.set_color(bg_color); canvas.draw_rect(rect, &fill); let mut stroke = Paint::default(); @@ -955,8 +955,7 @@ fn draw_form_control( path.close(); canvas.draw_path(&path, &arrow); - let display = if form.text.is_empty() { &form.caption } else { &form.text }; - if !display.is_empty() { + if !form.text.is_empty() { let mut font = Font::default(); font.set_size((h * 0.55).clamp(8.0, 13.0)); let mut tp = Paint::default(); @@ -964,14 +963,14 @@ fn draw_form_control( tp.set_color(fg_color); let tx = x + 4.0; let ty = y + h / 2.0 + font.size() * 0.35; - canvas.draw_str(display, (tx, ty), &font, &tp); + canvas.draw_str(&form.text, (tx, ty), &font, &tp); } } FormType::Edit => { let mut fill = Paint::default(); fill.set_anti_alias(true); fill.set_style(paint::Style::Fill); - fill.set_color(Color::WHITE); + fill.set_color(bg_color); canvas.draw_rect(rect, &fill); let mut stroke = Paint::default(); @@ -981,8 +980,7 @@ fn draw_form_control( stroke.set_color(border_color); canvas.draw_rect(rect, &stroke); - let display = if form.text.is_empty() { &form.caption } else { &form.text }; - if !display.is_empty() { + if !form.text.is_empty() { let mut font = Font::default(); font.set_size((h * 0.55).clamp(8.0, 13.0)); let mut tp = Paint::default(); @@ -990,7 +988,7 @@ fn draw_form_control( tp.set_color(fg_color); let tx = x + 4.0; let ty = y + h / 2.0 + font.size() * 0.35; - canvas.draw_str(display, (tx, ty), &font, &tp); + canvas.draw_str(&form.text, (tx, ty), &font, &tp); } } } From a8093f2793af48a1638dae2229cbce349a79cc80 Mon Sep 17 00:00:00 2001 From: Hyunwoo Park Date: Sat, 9 May 2026 12:26:18 +0000 Subject: [PATCH 3/4] =?UTF-8?q?CI=20=EC=88=98=EC=A0=95:=20Path::new()=20?= =?UTF-8?q?=E2=86=92=20PathBuilder=20=EC=82=AC=EC=9A=A9=20(native-skia=20?= =?UTF-8?q?=ED=98=B8=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/skia/renderer.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/renderer/skia/renderer.rs b/src/renderer/skia/renderer.rs index 3bd5f5126..f9965c46f 100644 --- a/src/renderer/skia/renderer.rs +++ b/src/renderer/skia/renderer.rs @@ -860,10 +860,11 @@ fn draw_form_control( let my = by + box_size * 0.75; let ex = bx + box_size * 0.8; let ey = by + box_size * 0.25; - let mut path = skia_safe::Path::new(); - path.move_to((cx, cy)); - path.line_to((mx, my)); - path.line_to((ex, ey)); + let mut builder = PathBuilder::new(); + builder.move_to((cx, cy)); + builder.line_to((mx, my)); + builder.line_to((ex, ey)); + let path = builder.detach(); canvas.draw_path(&path, &check); } @@ -948,11 +949,12 @@ fn draw_form_control( let acx = ax + arrow_w / 2.0; let acy = y + h / 2.0; let as_ = (arrow_w * 0.25).min(5.0); - let mut path = skia_safe::Path::new(); - path.move_to((acx - as_, acy - as_ * 0.5)); - path.line_to((acx + as_, acy - as_ * 0.5)); - path.line_to((acx, acy + as_ * 0.5)); - path.close(); + let mut builder = PathBuilder::new(); + builder.move_to((acx - as_, acy - as_ * 0.5)); + builder.line_to((acx + as_, acy - as_ * 0.5)); + builder.line_to((acx, acy + as_ * 0.5)); + builder.close(); + let path = builder.detach(); canvas.draw_path(&path, &arrow); if !form.text.is_empty() { From 3b5a0272b5f50cbd4c7e2fa6ca8941832a0ff5b5 Mon Sep 17 00:00:00 2001 From: Hyunwoo Park Date: Sat, 9 May 2026 19:30:09 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20form=20control=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A0=8C=EB=8D=94=EB=A7=81=20CJK=20fallback=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit draw_form_control을 SkiaLayerRenderer 메서드로 변환하여 font_mgr를 통한 한글 폰트 fallback chain 사용. - make_form_font(): CJK 폰트 후보 (맑은 고딕, 나눔고딕 등) → custom_typefaces → font_mgr → legacy fallback 순서로 탐색 - Font::default() 5개소 → self.make_form_font() 대체 - PushButton/CheckBox/RadioButton caption + ComboBox/Edit text 모두 동일 fallback chain 적용 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/skia/renderer.rs | 72 +++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/src/renderer/skia/renderer.rs b/src/renderer/skia/renderer.rs index f9965c46f..afa8d3023 100644 --- a/src/renderer/skia/renderer.rs +++ b/src/renderer/skia/renderer.rs @@ -755,7 +755,7 @@ impl SkiaLayerRenderer { canvas.restore(); } PaintOp::FormObject { bbox, form } => { - draw_form_control(canvas, *bbox, form); + self.draw_form_control(canvas, *bbox, form); } PaintOp::Placeholder { bbox, placeholder } => { draw_placeholder(*bbox, placeholder.label.as_str()); @@ -778,26 +778,47 @@ impl LayerRasterRenderer for SkiaLayerRenderer { } } -fn draw_form_control( - canvas: &Canvas, - bbox: crate::renderer::render_tree::BoundingBox, - form: &crate::renderer::render_tree::FormObjectNode, -) { - use crate::model::control::FormType; - - if bbox.width <= 0.0 || bbox.height <= 0.0 { - return; +impl SkiaLayerRenderer { + fn make_form_font(&self, size: f32) -> Font { + let style = FontStyle::default(); + let cjk_families = ["Malgun Gothic", "맑은 고딕", "NanumGothic", "나눔고딕", "AppleGothic"]; + for family in &cjk_families { + if let Some(tf) = self.custom_typefaces.get(*family).cloned() { + return Font::new(tf, size); + } + if let Some(tf) = self.font_mgr.match_family_style(family, style) { + return Font::new(tf, size); + } + } + if let Some(tf) = self.font_mgr.legacy_make_typeface(None::<&str>, style) { + return Font::new(tf, size); + } + let mut f = Font::default(); + f.set_size(size); + f } - let x = bbox.x as f32; - let y = bbox.y as f32; - let w = bbox.width as f32; - let h = bbox.height as f32; - let rect = Rect::from_xywh(x, y, w, h); + fn draw_form_control( + &self, + canvas: &Canvas, + bbox: crate::renderer::render_tree::BoundingBox, + form: &crate::renderer::render_tree::FormObjectNode, + ) { + use crate::model::control::FormType; + + if bbox.width <= 0.0 || bbox.height <= 0.0 { + return; + } + + let x = bbox.x as f32; + let y = bbox.y as f32; + let w = bbox.width as f32; + let h = bbox.height as f32; + let rect = Rect::from_xywh(x, y, w, h); - let bg_color = parse_css_color(&form.back_color).unwrap_or(Color::from_rgb(240, 240, 240)); - let fg_color = parse_css_color(&form.fore_color).unwrap_or(Color::from_rgb(0, 0, 0)); - let border_color = Color::from_rgb(160, 160, 160); + let bg_color = parse_css_color(&form.back_color).unwrap_or(Color::from_rgb(240, 240, 240)); + let fg_color = parse_css_color(&form.fore_color).unwrap_or(Color::from_rgb(0, 0, 0)); + let border_color = Color::from_rgb(160, 160, 160); match form.form_type { FormType::PushButton => { @@ -817,8 +838,7 @@ fn draw_form_control( let label = if form.caption.is_empty() { &form.name } else { &form.caption }; if !label.is_empty() { - let mut font = Font::default(); - font.set_size((h * 0.45).clamp(8.0, 14.0)); + let font = self.make_form_font((h * 0.45).clamp(8.0, 14.0)); let mut tp = Paint::default(); tp.set_anti_alias(true); tp.set_color(fg_color); @@ -869,8 +889,7 @@ fn draw_form_control( } if !form.caption.is_empty() { - let mut font = Font::default(); - font.set_size((h * 0.6).clamp(8.0, 13.0)); + let font = self.make_form_font((h * 0.6).clamp(8.0, 13.0)); let mut tp = Paint::default(); tp.set_anti_alias(true); tp.set_color(fg_color); @@ -906,8 +925,7 @@ fn draw_form_control( } if !form.caption.is_empty() { - let mut font = Font::default(); - font.set_size((h * 0.6).clamp(8.0, 13.0)); + let font = self.make_form_font((h * 0.6).clamp(8.0, 13.0)); let mut tp = Paint::default(); tp.set_anti_alias(true); tp.set_color(fg_color); @@ -958,8 +976,7 @@ fn draw_form_control( canvas.draw_path(&path, &arrow); if !form.text.is_empty() { - let mut font = Font::default(); - font.set_size((h * 0.55).clamp(8.0, 13.0)); + let font = self.make_form_font((h * 0.55).clamp(8.0, 13.0)); let mut tp = Paint::default(); tp.set_anti_alias(true); tp.set_color(fg_color); @@ -983,8 +1000,7 @@ fn draw_form_control( canvas.draw_rect(rect, &stroke); if !form.text.is_empty() { - let mut font = Font::default(); - font.set_size((h * 0.55).clamp(8.0, 13.0)); + let font = self.make_form_font((h * 0.55).clamp(8.0, 13.0)); let mut tp = Paint::default(); tp.set_anti_alias(true); tp.set_color(fg_color);