feat(canvas): render mesh-gradient + sksl shader fills natively
The mesh/shader migration only papered over the compile errors: the canvas dispatched Mesh bodies to a flat first-vertex fill and never dispatched SceneNode.shader at all, even though the jian Painter trait already carries fill_round_rect_mesh_gradient / fill_round_rect_shader with real Skia implementations available. Port jian-skia's Gouraud vertices + cached-RuntimeEffect paint onto NativeBackend (sibling mesh_shader.rs, ShaderCache shared with jian-skia so sources compile once), delegate through NativeFrameBackend, and route the canvas fill dispatch through the trait methods — shader wins over gradient over solid. CanvasKit/web inherits the trait's first-vertex / fallback-solid defaults (documented parity gap, same as jian's own non-native painters). Pixel-level raster tests prove interpolation actually happens and a compiled shader beats its fallback.
This commit is contained in:
parent
c0e4f654d2
commit
2d0bc74cbe
|
|
@ -115,11 +115,25 @@ pub fn paint_fill_then_stroke(
|
|||
) {
|
||||
let r = node.corner_radius * zoom;
|
||||
let use_round = r > 0.5;
|
||||
// Gradients win over solid `fill` when present. The scene
|
||||
// builder leaves the first stop's colour in `fill` as a
|
||||
// fallback for paint paths that don't grok gradients yet, so
|
||||
// the gradient body is the more faithful representation here.
|
||||
if let Some(gradient) = node.gradient.as_ref() {
|
||||
// Shader / gradient bodies win over solid `fill` when present. The
|
||||
// scene builder leaves a representative colour in `fill` as a
|
||||
// fallback for paint paths that don't grok them yet, so the richer
|
||||
// body is the more faithful representation here.
|
||||
if let Some(shader) = node.shader.as_ref() {
|
||||
let uniforms: Vec<(&str, &[f32])> = shader
|
||||
.uniforms
|
||||
.iter()
|
||||
.map(|u| (u.name.as_str(), u.values.as_slice()))
|
||||
.collect();
|
||||
cx.backend.fill_round_rect_shader(
|
||||
world_rect,
|
||||
if use_round { r } else { 0.0 },
|
||||
&shader.sksl,
|
||||
&uniforms,
|
||||
shader.opacity,
|
||||
shader.fallback,
|
||||
);
|
||||
} else if let Some(gradient) = node.gradient.as_ref() {
|
||||
paint_gradient_rect(cx, gradient, world_rect, if use_round { r } else { 0.0 });
|
||||
} else if let Some(fill) = fill {
|
||||
if use_round {
|
||||
|
|
@ -243,16 +257,23 @@ pub(crate) fn paint_gradient_rect(
|
|||
);
|
||||
}
|
||||
SceneGradient::Mesh {
|
||||
colors, opacity, ..
|
||||
rows,
|
||||
cols,
|
||||
colors,
|
||||
opacity,
|
||||
} => {
|
||||
// No mesh-gradient method on the RenderBackend yet —
|
||||
// degrade to the first vertex colour (same fallback the
|
||||
// scene builder bakes into `SceneNode.fill`).
|
||||
if let Some(first) = colors.first() {
|
||||
let mut c = *first;
|
||||
c.a = (c.a * opacity).clamp(0.0, 1.0);
|
||||
cx.backend.fill_round_rect(rect, corner_radius, c);
|
||||
}
|
||||
// Real Gouraud interpolation on backends that override the
|
||||
// Painter method (the native Skia host); the trait default
|
||||
// degrades to the first vertex colour elsewhere (CanvasKit
|
||||
// web — documented parity gap, same as jian's own painters).
|
||||
cx.backend.fill_round_rect_mesh_gradient(
|
||||
rect,
|
||||
corner_radius,
|
||||
*rows,
|
||||
*cols,
|
||||
colors,
|
||||
*opacity,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ struct RecordingBackend {
|
|||
strokes: usize,
|
||||
text: usize,
|
||||
dots: usize,
|
||||
mesh_fills: usize,
|
||||
shader_fills: usize,
|
||||
}
|
||||
|
||||
impl crate::RenderBackend for RecordingBackend {
|
||||
|
|
@ -74,6 +76,30 @@ impl crate::RenderBackend for RecordingBackend {
|
|||
self.strokes += 1;
|
||||
self.ops.push(Op::Stroke);
|
||||
}
|
||||
fn fill_round_rect_mesh_gradient(
|
||||
&mut self,
|
||||
_: Rect,
|
||||
_: f32,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: &[Color],
|
||||
_: f32,
|
||||
) {
|
||||
self.mesh_fills += 1;
|
||||
self.ops.push(Op::Fill);
|
||||
}
|
||||
fn fill_round_rect_shader(
|
||||
&mut self,
|
||||
_: Rect,
|
||||
_: f32,
|
||||
_: &str,
|
||||
_: &[(&str, &[f32])],
|
||||
_: f32,
|
||||
_: Color,
|
||||
) {
|
||||
self.shader_fills += 1;
|
||||
self.ops.push(Op::Fill);
|
||||
}
|
||||
fn resize(&mut self, _: u32, _: u32) {}
|
||||
fn dpi_scale(&self) -> f32 {
|
||||
1.0
|
||||
|
|
@ -798,3 +824,80 @@ fn arc_handle_positions_none_for_non_ellipse() {
|
|||
node.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
|
||||
assert!(super::arc_handle_positions(&node).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_fill_dispatches_through_the_shader_backend_method() {
|
||||
use crate::layout_scene::{SceneShader, SceneShaderUniform};
|
||||
let mut node = SceneNode::leaf("s1", NodeKind::Rect);
|
||||
node.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
|
||||
node.fill = Some(Color::WHITE); // fallback colour the builder bakes in
|
||||
node.fill_type = SceneFillType::Shader;
|
||||
node.shader = Some(SceneShader {
|
||||
sksl: "half4 main(float2 p){ return half4(1.0,0.0,0.0,1.0); }".to_string(),
|
||||
uniforms: vec![SceneShaderUniform {
|
||||
name: "u_mix".to_string(),
|
||||
values: vec![0.5],
|
||||
}],
|
||||
opacity: 1.0,
|
||||
fallback: Color::WHITE,
|
||||
});
|
||||
|
||||
let mut backend = RecordingBackend::default();
|
||||
{
|
||||
let mut cx = PaintCx {
|
||||
backend: &mut backend,
|
||||
};
|
||||
crate::widgets::canvas_viewport_overlay::paint_fill_then_stroke(
|
||||
&mut cx,
|
||||
&node,
|
||||
node.bounds,
|
||||
1.0,
|
||||
node.fill,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
backend.shader_fills, 1,
|
||||
"shader body must route through fill_round_rect_shader, not the solid fill"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.rects, 0,
|
||||
"no plain-fill fallback when a shader is present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_gradient_dispatches_through_the_mesh_backend_method() {
|
||||
use crate::layout_scene::SceneGradient;
|
||||
let mut node = SceneNode::leaf("m1", NodeKind::Rect);
|
||||
node.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
|
||||
node.fill = Some(Color::WHITE);
|
||||
node.fill_type = SceneFillType::MeshGradient;
|
||||
node.gradient = Some(SceneGradient::Mesh {
|
||||
rows: 2,
|
||||
cols: 2,
|
||||
colors: vec![Color::WHITE; 4],
|
||||
opacity: 1.0,
|
||||
});
|
||||
|
||||
let mut backend = RecordingBackend::default();
|
||||
{
|
||||
let mut cx = PaintCx {
|
||||
backend: &mut backend,
|
||||
};
|
||||
crate::widgets::canvas_viewport_overlay::paint_fill_then_stroke(
|
||||
&mut cx,
|
||||
&node,
|
||||
node.bounds,
|
||||
1.0,
|
||||
node.fill,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
backend.mesh_fills, 1,
|
||||
"mesh body must route through fill_round_rect_mesh_gradient"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.rects, 0,
|
||||
"no flat first-vertex fill at the dispatch layer"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,6 +237,46 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> {
|
|||
);
|
||||
}
|
||||
|
||||
fn fill_round_rect_mesh_gradient(
|
||||
&mut self,
|
||||
rect: Rect,
|
||||
radius: f32,
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
colors: &[Color],
|
||||
opacity: f32,
|
||||
) {
|
||||
self.inner.fill_round_rect_mesh_gradient(
|
||||
self.canvas,
|
||||
rect,
|
||||
radius,
|
||||
rows,
|
||||
cols,
|
||||
colors,
|
||||
opacity,
|
||||
);
|
||||
}
|
||||
|
||||
fn fill_round_rect_shader(
|
||||
&mut self,
|
||||
rect: Rect,
|
||||
radius: f32,
|
||||
sksl: &str,
|
||||
uniforms: &[(&str, &[f32])],
|
||||
opacity: f32,
|
||||
fallback: Color,
|
||||
) {
|
||||
self.inner.fill_round_rect_shader(
|
||||
self.canvas,
|
||||
rect,
|
||||
radius,
|
||||
sksl,
|
||||
uniforms,
|
||||
opacity,
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn fill_round_rect_radial_gradient(
|
||||
&mut self,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ pub(super) fn jian_color_to_color4f(c: Color) -> skia_safe::Color4f {
|
|||
mod font_script;
|
||||
mod gradient;
|
||||
mod image;
|
||||
mod mesh_shader;
|
||||
mod path;
|
||||
mod text;
|
||||
#[cfg(test)]
|
||||
|
|
@ -132,6 +133,11 @@ pub struct NativeBackend {
|
|||
svg_raster_cache: std::collections::HashMap<path::SvgRasterKey, path::SvgRasterCacheEntry>,
|
||||
svg_raster_cache_order: std::collections::VecDeque<path::SvgRasterKey>,
|
||||
dot_point_buffer: Vec<skia_safe::Point>,
|
||||
/// Compiled-SkSL `RuntimeEffect` cache for shader fills — compiles
|
||||
/// each distinct source once (failures cached too), same rationale
|
||||
/// as the typeface caches above. Shared type with jian-skia so both
|
||||
/// render paths key sources identically.
|
||||
shader_cache: jian_skia::shader_cache::ShaderCache,
|
||||
}
|
||||
|
||||
/// Maximum number of decoded chat images held at once. Decoded RGBA
|
||||
|
|
@ -198,6 +204,7 @@ impl NativeBackend {
|
|||
svg_raster_cache: std::collections::HashMap::new(),
|
||||
svg_raster_cache_order: std::collections::VecDeque::new(),
|
||||
dot_point_buffer: Vec::new(),
|
||||
shader_cache: jian_skia::shader_cache::ShaderCache::new(),
|
||||
};
|
||||
// Pre-warm the per-codepoint typeface cache with every CJK
|
||||
// glyph that appears in the chrome (top bar, layer panel,
|
||||
|
|
|
|||
275
crates/op-host-native/src/backend/skia/mesh_shader.rs
Normal file
275
crates/op-host-native/src/backend/skia/mesh_shader.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
//! Mesh-gradient + SkSL-shader fill methods on [`NativeBackend`].
|
||||
//!
|
||||
//! Ports jian-skia's `draw_mesh_gradient_rect` / `draw_shader_rect`
|
||||
//! (vendor/jian/crates/jian-skia/src/backend.rs) onto the editor's
|
||||
//! direct-canvas painter so canvas nodes with `PenFill::MeshGradient`
|
||||
//! / `PenFill::Shader` render for real on the native host instead of
|
||||
//! falling back to the jian `Painter` trait's first-vertex / fallback
|
||||
//! solid defaults (which remain the web/CanvasKit behaviour — the
|
||||
//! documented parity gap). Carved into a sibling file per the 800-line
|
||||
//! cap, same as `gradient.rs`.
|
||||
|
||||
use super::{jian_color_to_color4f, to_sk_rect, NativeBackend};
|
||||
use op_editor_ui::{Color, Rect};
|
||||
|
||||
impl NativeBackend {
|
||||
/// Gouraud-fill a (round-)rect from a row-major `rows`×`cols`
|
||||
/// vertex-colour lattice. Grid cells triangulate into two CCW
|
||||
/// triangles each; `opacity` rides in the paint alpha so the
|
||||
/// vertex colours stay at their authored values.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn fill_round_rect_mesh_gradient(
|
||||
&self,
|
||||
canvas: &skia_safe::Canvas,
|
||||
rect: Rect,
|
||||
radius: f32,
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
colors: &[Color],
|
||||
opacity: f32,
|
||||
) {
|
||||
use skia_safe::{vertices::VertexMode, BlendMode, Color4f, Paint, Vertices};
|
||||
|
||||
let rows = rows.max(2);
|
||||
let cols = cols.max(2);
|
||||
let vcount = (rows * cols) as usize;
|
||||
if colors.len() != vcount {
|
||||
// Malformed lattice — paint the first-vertex fallback so the
|
||||
// node stays visible (mirrors the trait default).
|
||||
if let Some(first) = colors.first() {
|
||||
let mut c = *first;
|
||||
c.a = (c.a * opacity).clamp(0.0, 1.0);
|
||||
self.fill_round_rect(canvas, rect, radius, c);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut positions: Vec<skia_safe::Point> = Vec::with_capacity(vcount);
|
||||
let mut sk_colors: Vec<skia_safe::Color> = Vec::with_capacity(vcount);
|
||||
let denom_c = (cols - 1) as f32;
|
||||
let denom_r = (rows - 1) as f32;
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
let fx = rect.origin.x + (c as f32 / denom_c) * rect.size.x;
|
||||
let fy = rect.origin.y + (r as f32 / denom_r) * rect.size.y;
|
||||
positions.push(skia_safe::Point::new(fx, fy));
|
||||
let jc = colors[(r * cols + c) as usize];
|
||||
sk_colors.push(jian_color_to_color4f(jc).to_color());
|
||||
}
|
||||
}
|
||||
|
||||
// Index of vertex (r, c) is `r * cols + c`.
|
||||
let mut indices: Vec<u16> = Vec::with_capacity(((rows - 1) * (cols - 1) * 6) as usize);
|
||||
for r in 0..(rows - 1) {
|
||||
for c in 0..(cols - 1) {
|
||||
let tl = (r * cols + c) as u16;
|
||||
let tr = (r * cols + c + 1) as u16;
|
||||
let bl = ((r + 1) * cols + c) as u16;
|
||||
let br = ((r + 1) * cols + c + 1) as u16;
|
||||
indices.extend_from_slice(&[tl, tr, bl, tr, br, bl]);
|
||||
}
|
||||
}
|
||||
|
||||
let vertices = Vertices::new_copy(
|
||||
VertexMode::Triangles,
|
||||
&positions,
|
||||
&positions,
|
||||
&sk_colors,
|
||||
Some(&indices),
|
||||
);
|
||||
|
||||
// Clip to the round-rect so the Gouraud fill respects corner
|
||||
// radius, then draw. A default Paint is opaque black — seed it
|
||||
// white and Modulate (white × vertex == vertex) so the vertex
|
||||
// colours pass through unchanged; `opacity` rides in the alpha.
|
||||
let restore_count = canvas.save();
|
||||
if radius > 0.0 {
|
||||
let rrect = skia_safe::RRect::new_rect_xy(to_sk_rect(rect), radius, radius);
|
||||
canvas.clip_rrect(rrect, None, Some(true));
|
||||
} else {
|
||||
canvas.clip_rect(to_sk_rect(rect), None, Some(true));
|
||||
}
|
||||
let mut paint = Paint::default();
|
||||
paint.set_anti_alias(true);
|
||||
paint.set_color4f(Color4f::new(1.0, 1.0, 1.0, opacity.clamp(0.0, 1.0)), None);
|
||||
canvas.draw_vertices(&vertices, BlendMode::Modulate, &paint);
|
||||
canvas.restore_to_count(restore_count);
|
||||
}
|
||||
|
||||
/// Fill a (round-)rect with a compiled SkSL program. The compile is
|
||||
/// cached per distinct source (failures too) in `shader_cache`;
|
||||
/// uniform bind errors are ignored per-uniform so one bad binding
|
||||
/// doesn't sink the fill. On compile failure the visible `fallback`
|
||||
/// solid paints instead.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn fill_round_rect_shader(
|
||||
&mut self,
|
||||
canvas: &skia_safe::Canvas,
|
||||
rect: Rect,
|
||||
radius: f32,
|
||||
sksl: &str,
|
||||
uniforms: &[(&str, &[f32])],
|
||||
opacity: f32,
|
||||
fallback: Color,
|
||||
) {
|
||||
use skia_safe::runtime_effect::RuntimeShaderBuilder;
|
||||
use skia_safe::{Paint, PaintStyle};
|
||||
|
||||
let shader = self.shader_cache.get_or_compile(sksl).and_then(|effect| {
|
||||
// `RuntimeEffect` is an RCHandle — the clone handed to the
|
||||
// builder is a refcount bump, not a recompile.
|
||||
let mut builder = RuntimeShaderBuilder::new(effect);
|
||||
for (name, values) in uniforms {
|
||||
let _ = builder.set_uniform_float(name, values);
|
||||
}
|
||||
builder.make_shader(&skia_safe::Matrix::default())
|
||||
});
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_anti_alias(true);
|
||||
paint.set_style(PaintStyle::Fill);
|
||||
match shader {
|
||||
Some(s) => {
|
||||
paint.set_shader(s);
|
||||
paint.set_alpha_f(opacity.clamp(0.0, 1.0));
|
||||
}
|
||||
None => {
|
||||
let mut c = jian_color_to_color4f(fallback);
|
||||
c.a = (c.a * opacity).clamp(0.0, 1.0);
|
||||
paint.set_color4f(c, None);
|
||||
}
|
||||
}
|
||||
if radius > 0.0 {
|
||||
let rrect = skia_safe::RRect::new_rect_xy(to_sk_rect(rect), radius, radius);
|
||||
canvas.draw_rrect(rrect, &paint);
|
||||
} else {
|
||||
canvas.draw_rect(to_sk_rect(rect), &paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use op_editor_ui::Point2D;
|
||||
|
||||
fn raster_canvas_run(
|
||||
w: i32,
|
||||
h: i32,
|
||||
f: impl FnOnce(&mut NativeBackend, &skia_safe::Canvas),
|
||||
) -> Vec<u8> {
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
|
||||
let mut backend = NativeBackend::with_dpi(1.0);
|
||||
f(&mut backend, surface.canvas());
|
||||
let image = surface.image_snapshot();
|
||||
let pixmap = image.peek_pixels().expect("cpu raster pixels");
|
||||
pixmap.bytes().expect("pixel bytes").to_vec()
|
||||
}
|
||||
|
||||
fn rect_full(w: f32, h: f32) -> Rect {
|
||||
Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(w, h),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_gradient_interpolates_between_vertex_colors() {
|
||||
// 2×2 lattice: red / red on top, blue / blue on bottom — the
|
||||
// vertical midline must be neither pure red nor pure blue.
|
||||
let red = Color {
|
||||
r: 1.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
};
|
||||
let blue = Color {
|
||||
r: 0.0,
|
||||
g: 0.0,
|
||||
b: 1.0,
|
||||
a: 1.0,
|
||||
};
|
||||
let px = raster_canvas_run(64, 64, |b, canvas| {
|
||||
b.fill_round_rect_mesh_gradient(
|
||||
canvas,
|
||||
rect_full(64.0, 64.0),
|
||||
0.0,
|
||||
2,
|
||||
2,
|
||||
&[red, red, blue, blue],
|
||||
1.0,
|
||||
);
|
||||
});
|
||||
// BGRA/RGBA layout differs per platform; compare channel maxes
|
||||
// instead of fixed offsets. Top row ≈ red-ish, bottom ≈ blue-ish,
|
||||
// middle has BOTH components clearly present (interpolated).
|
||||
let at = |x: usize, y: usize| -> (u8, u8, u8) {
|
||||
let i = (y * 64 + x) * 4;
|
||||
(px[i], px[i + 1], px[i + 2])
|
||||
};
|
||||
let (m0, m1, m2) = at(32, 32);
|
||||
let mid_channels = [m0, m1, m2];
|
||||
let strong = mid_channels.iter().filter(|v| **v > 60).count();
|
||||
assert!(
|
||||
strong >= 2,
|
||||
"midline should be an interpolated mix, got {mid_channels:?}"
|
||||
);
|
||||
let top = at(32, 2);
|
||||
let bottom = at(32, 61);
|
||||
assert_ne!(top, bottom, "gradient must vary across the lattice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_fill_compiles_and_beats_the_fallback() {
|
||||
let green_fallback = Color {
|
||||
r: 0.0,
|
||||
g: 1.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
};
|
||||
// Constant-red program: any pixel being red proves the shader
|
||||
// ran; green would mean the fallback painted instead.
|
||||
let px = raster_canvas_run(16, 16, |b, canvas| {
|
||||
b.fill_round_rect_shader(
|
||||
canvas,
|
||||
rect_full(16.0, 16.0),
|
||||
0.0,
|
||||
"half4 main(float2 p){ return half4(1.0, 0.0, 0.0, 1.0); }",
|
||||
&[],
|
||||
1.0,
|
||||
green_fallback,
|
||||
);
|
||||
});
|
||||
let i = (8 * 16 + 8) * 4;
|
||||
let channels = [px[i], px[i + 1], px[i + 2]];
|
||||
assert!(
|
||||
channels.contains(&255) && channels.contains(&0),
|
||||
"expected pure shader red, got {channels:?}"
|
||||
);
|
||||
// Green channel must NOT be the strong one (that's the fallback).
|
||||
assert!(px[i + 1] < 60, "fallback green leaked: {channels:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_compile_failure_paints_fallback() {
|
||||
let fallback = Color {
|
||||
r: 0.0,
|
||||
g: 1.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
};
|
||||
let px = raster_canvas_run(8, 8, |b, canvas| {
|
||||
b.fill_round_rect_shader(
|
||||
canvas,
|
||||
rect_full(8.0, 8.0),
|
||||
0.0,
|
||||
"this is not sksl",
|
||||
&[],
|
||||
1.0,
|
||||
fallback,
|
||||
);
|
||||
});
|
||||
let i = (4 * 8 + 4) * 4;
|
||||
assert_eq!(px[i + 1], 255, "fallback green expected");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue