mirror of
https://github.com/slint-ui/slint.git
synced 2025-10-03 15:14:35 +00:00
Replace the MAX_BUFFER_AGE const generic with a runtime enum
Having a const generic for that didn't turn to be a good API. Also made the C++ side more difficult (Also renamed buffer_stride to pixel_stride) Closes #2135
This commit is contained in:
parent
05e00fe057
commit
a19efc30db
12 changed files with 134 additions and 148 deletions
|
@ -17,6 +17,8 @@ All notable changes to this project are documented in this file.
|
||||||
a `slint::SharedString`.
|
a `slint::SharedString`.
|
||||||
- `slint::platform::WindowEvent` does not derive from `Copy` anymore. You must `clone()` it
|
- `slint::platform::WindowEvent` does not derive from `Copy` anymore. You must `clone()` it
|
||||||
explicitly if you want to create a copy.
|
explicitly if you want to create a copy.
|
||||||
|
- In Rust, the MAX_BUFFER_AGE const parameter of `slint::platform::software_renderer::MinimalSoftwareWindow`
|
||||||
|
has been removed and replaced by an argument to the `new()` function
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|
|
@ -194,12 +194,6 @@ public:
|
||||||
/// To be used as a template parameter of the WindowAdapter.
|
/// To be used as a template parameter of the WindowAdapter.
|
||||||
///
|
///
|
||||||
/// Use the render() function to render in a buffer
|
/// Use the render() function to render in a buffer
|
||||||
///
|
|
||||||
/// The MAX_BUFFER_AGE parameter specifies how many buffers are being re-used.
|
|
||||||
/// This means that the buffer passed to the render functions still contains a rendering of
|
|
||||||
/// the window that was refreshed as least that amount of frame ago.
|
|
||||||
/// It will impact how much of the screen needs to be redrawn.
|
|
||||||
template<int MAX_BUFFER_AGE = 0>
|
|
||||||
class SoftwareRenderer
|
class SoftwareRenderer
|
||||||
{
|
{
|
||||||
mutable cbindgen_private::SoftwareRendererOpaque inner;
|
mutable cbindgen_private::SoftwareRendererOpaque inner;
|
||||||
|
@ -208,7 +202,7 @@ public:
|
||||||
virtual ~SoftwareRenderer()
|
virtual ~SoftwareRenderer()
|
||||||
{
|
{
|
||||||
if (inner) {
|
if (inner) {
|
||||||
cbindgen_private::slint_software_renderer_drop(MAX_BUFFER_AGE, inner);
|
cbindgen_private::slint_software_renderer_drop(inner);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
SoftwareRenderer(const SoftwareRenderer &) = delete;
|
SoftwareRenderer(const SoftwareRenderer &) = delete;
|
||||||
|
@ -216,18 +210,18 @@ public:
|
||||||
SoftwareRenderer() = default;
|
SoftwareRenderer() = default;
|
||||||
|
|
||||||
/// \private
|
/// \private
|
||||||
void init(const cbindgen_private::WindowAdapterRcOpaque *win) const
|
void init(const cbindgen_private::WindowAdapterRcOpaque *win, int max_buffer_age) const
|
||||||
{
|
{
|
||||||
if (inner) {
|
if (inner) {
|
||||||
cbindgen_private::slint_software_renderer_drop(MAX_BUFFER_AGE, inner);
|
cbindgen_private::slint_software_renderer_drop(inner);
|
||||||
}
|
}
|
||||||
inner = cbindgen_private::slint_software_renderer_new(MAX_BUFFER_AGE, win);
|
inner = cbindgen_private::slint_software_renderer_new(max_buffer_age, win);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// \private
|
/// \private
|
||||||
cbindgen_private::RendererPtr renderer_handle() const
|
cbindgen_private::RendererPtr renderer_handle() const
|
||||||
{
|
{
|
||||||
return cbindgen_private::slint_software_renderer_handle(MAX_BUFFER_AGE, inner);
|
return cbindgen_private::slint_software_renderer_handle(inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the window scene into a pixel buffer
|
/// Render the window scene into a pixel buffer
|
||||||
|
@ -236,10 +230,11 @@ public:
|
||||||
///
|
///
|
||||||
/// The stride is the amount of pixels between two lines in the buffer.
|
/// The stride is the amount of pixels between two lines in the buffer.
|
||||||
/// It is must be at least as large as the width of the window.
|
/// It is must be at least as large as the width of the window.
|
||||||
void render(std::span<slint::cbindgen_private::Rgb8Pixel> buffer, std::size_t stride) const
|
void render(std::span<slint::cbindgen_private::Rgb8Pixel> buffer,
|
||||||
|
std::size_t pixel_stride) const
|
||||||
{
|
{
|
||||||
cbindgen_private::slint_software_renderer_render_rgb8(MAX_BUFFER_AGE, inner, buffer.data(),
|
cbindgen_private::slint_software_renderer_render_rgb8(inner, buffer.data(), buffer.size(),
|
||||||
buffer.size(), stride);
|
pixel_stride);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ use i_slint_core::api::{PhysicalSize, Window};
|
||||||
use i_slint_core::graphics::{IntSize, Rgb8Pixel};
|
use i_slint_core::graphics::{IntSize, Rgb8Pixel};
|
||||||
use i_slint_core::platform::Platform;
|
use i_slint_core::platform::Platform;
|
||||||
use i_slint_core::renderer::Renderer;
|
use i_slint_core::renderer::Renderer;
|
||||||
use i_slint_core::software_renderer::SoftwareRenderer;
|
use i_slint_core::software_renderer::{RepaintBufferType, SoftwareRenderer};
|
||||||
use i_slint_core::window::ffi::WindowAdapterRcOpaque;
|
use i_slint_core::window::ffi::WindowAdapterRcOpaque;
|
||||||
use i_slint_core::window::{WindowAdapter, WindowAdapterSealed};
|
use i_slint_core::window::{WindowAdapter, WindowAdapterSealed};
|
||||||
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||||
|
@ -143,52 +143,35 @@ pub unsafe extern "C" fn slint_software_renderer_new(
|
||||||
) -> SoftwareRendererOpaque {
|
) -> SoftwareRendererOpaque {
|
||||||
let window = core::mem::transmute::<&WindowAdapterRcOpaque, &Rc<dyn WindowAdapter>>(window);
|
let window = core::mem::transmute::<&WindowAdapterRcOpaque, &Rc<dyn WindowAdapter>>(window);
|
||||||
let weak = Rc::downgrade(window);
|
let weak = Rc::downgrade(window);
|
||||||
match buffer_age {
|
let repaint_buffer_type = match buffer_age {
|
||||||
0 => Box::into_raw(Box::new(SoftwareRenderer::<0>::new(weak))) as SoftwareRendererOpaque,
|
0 => RepaintBufferType::NewBuffer,
|
||||||
1 => Box::into_raw(Box::new(SoftwareRenderer::<1>::new(weak))) as SoftwareRendererOpaque,
|
1 => RepaintBufferType::ReusedBuffer,
|
||||||
2 => Box::into_raw(Box::new(SoftwareRenderer::<2>::new(weak))) as SoftwareRendererOpaque,
|
2 => RepaintBufferType::SwappedBuffers,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
};
|
||||||
|
Box::into_raw(Box::new(SoftwareRenderer::new(repaint_buffer_type, weak)))
|
||||||
|
as SoftwareRendererOpaque
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub unsafe extern "C" fn slint_software_renderer_drop(buffer_age: u32, r: SoftwareRendererOpaque) {
|
pub unsafe extern "C" fn slint_software_renderer_drop(r: SoftwareRendererOpaque) {
|
||||||
match buffer_age {
|
drop(Box::from_raw(r as *mut SoftwareRenderer));
|
||||||
0 => drop(Box::from_raw(r as *mut SoftwareRenderer<0>)),
|
|
||||||
1 => drop(Box::from_raw(r as *mut SoftwareRenderer<1>)),
|
|
||||||
2 => drop(Box::from_raw(r as *mut SoftwareRenderer<2>)),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub unsafe extern "C" fn slint_software_renderer_render_rgb8(
|
pub unsafe extern "C" fn slint_software_renderer_render_rgb8(
|
||||||
buffer_age: u32,
|
|
||||||
r: SoftwareRendererOpaque,
|
r: SoftwareRendererOpaque,
|
||||||
buffer: *mut Rgb8Pixel,
|
buffer: *mut Rgb8Pixel,
|
||||||
buffer_len: usize,
|
buffer_len: usize,
|
||||||
buffer_stride: usize,
|
pixel_stride: usize,
|
||||||
) {
|
) {
|
||||||
let buffer = core::slice::from_raw_parts_mut(buffer, buffer_len);
|
let buffer = core::slice::from_raw_parts_mut(buffer, buffer_len);
|
||||||
match buffer_age {
|
(*(r as *const SoftwareRenderer)).render(buffer, pixel_stride)
|
||||||
0 => (*(r as *const SoftwareRenderer<0>)).render(buffer, buffer_stride),
|
|
||||||
1 => (*(r as *const SoftwareRenderer<1>)).render(buffer, buffer_stride),
|
|
||||||
2 => (*(r as *const SoftwareRenderer<2>)).render(buffer, buffer_stride),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub unsafe extern "C" fn slint_software_renderer_handle(
|
pub unsafe extern "C" fn slint_software_renderer_handle(r: SoftwareRendererOpaque) -> RendererPtr {
|
||||||
buffer_age: u32,
|
let r = (r as *const SoftwareRenderer) as *const dyn Renderer;
|
||||||
r: SoftwareRendererOpaque,
|
|
||||||
) -> RendererPtr {
|
|
||||||
let r = match buffer_age {
|
|
||||||
0 => (r as *const SoftwareRenderer<0>) as *const dyn Renderer,
|
|
||||||
1 => (r as *const SoftwareRenderer<1>) as *const dyn Renderer,
|
|
||||||
2 => (r as *const SoftwareRenderer<2>) as *const dyn Renderer,
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
core::mem::transmute(r)
|
core::mem::transmute(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -131,7 +131,7 @@ slint::include_modules!();
|
||||||
# */
|
# */
|
||||||
|
|
||||||
struct MyPlatform {
|
struct MyPlatform {
|
||||||
window: Rc<MinimalSoftwareWindow<2>>,
|
window: Rc<MinimalSoftwareWindow>,
|
||||||
// optional: some timer device from your device's HAL crate
|
// optional: some timer device from your device's HAL crate
|
||||||
timer: hal::Timer,
|
timer: hal::Timer,
|
||||||
// ... maybe more devices
|
// ... maybe more devices
|
||||||
|
@ -158,7 +158,7 @@ fn main() {
|
||||||
// ...
|
// ...
|
||||||
|
|
||||||
// Initialize a window (we'll need it later).
|
// Initialize a window (we'll need it later).
|
||||||
let window = MinimalSoftwareWindow::new();
|
let window = MinimalSoftwareWindow::new(Default::default());
|
||||||
slint::platform::set_platform(Box::new(MyPlatform {
|
slint::platform::set_platform(Box::new(MyPlatform {
|
||||||
window: window.clone(),
|
window: window.clone(),
|
||||||
timer: hal::Timer(/*...*/),
|
timer: hal::Timer(/*...*/),
|
||||||
|
@ -195,8 +195,8 @@ A typical super loop with Slint combines the tasks of querying input drivers, ap
|
||||||
rendering and possibly putting the device into a low-power sleep state. Below is an example:
|
rendering and possibly putting the device into a low-power sleep state. Below is an example:
|
||||||
|
|
||||||
```rust,no_run
|
```rust,no_run
|
||||||
use slint::platform::{software_renderer::MinimalSoftwareWindow};
|
use slint::platform::software_renderer::MinimalSoftwareWindow;
|
||||||
let window = MinimalSoftwareWindow::<0>::new();
|
let window = MinimalSoftwareWindow::new(Default::default());
|
||||||
# fn check_for_touch_event() -> Option<slint::platform::WindowEvent> { todo!() }
|
# fn check_for_touch_event() -> Option<slint::platform::WindowEvent> { todo!() }
|
||||||
# mod hal { pub fn wfi() {} }
|
# mod hal { pub fn wfi() {} }
|
||||||
//...
|
//...
|
||||||
|
@ -260,10 +260,11 @@ the second buffer, the back buffer.
|
||||||
use slint::platform::software_renderer::Rgb565Pixel;
|
use slint::platform::software_renderer::Rgb565Pixel;
|
||||||
# fn is_swap_pending()->bool {false} fn swap_buffers() {}
|
# fn is_swap_pending()->bool {false} fn swap_buffers() {}
|
||||||
|
|
||||||
// Note that we use `2` as the const generic parameter which is our buffer count,
|
// In this example, we have two buffer: one is currently displayed, and we are
|
||||||
// since we have two buffer, we always need to refresh what changed in the two
|
// rendering into the second one. Hence we use `RepaintBufferType::SwappedBuffers`
|
||||||
// previous frames
|
let window = slint::platform::software_renderer::MinimalSoftwareWindow::new(
|
||||||
let window = slint::platform::software_renderer::MinimalSoftwareWindow::<2>::new();
|
slint::platform::software_renderer::RepaintBufferType::SwappedBuffers
|
||||||
|
);
|
||||||
|
|
||||||
const DISPLAY_WIDTH: usize = 320;
|
const DISPLAY_WIDTH: usize = 320;
|
||||||
const DISPLAY_HEIGHT: usize = 240;
|
const DISPLAY_HEIGHT: usize = 240;
|
||||||
|
@ -357,11 +358,13 @@ impl<T: DrawTarget<Color = embedded_graphics_core::pixelcolor::Rgb565>>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note that we use `1` as the const generic parameter for MinimalSoftwareWindow to indicate
|
// Note that we use `ReusedBuffer` as parameter for MinimalSoftwareWindow to indicate
|
||||||
// the maximum age of the buffer we provide to `render_fn` inside `process_line`.
|
// that we just need to re-render what changed since the last frame.
|
||||||
// What's shown on the screen buffer is not in our RAM, but actually within the display itself.
|
// What's shown on the screen buffer is not in our RAM, but actually within the display itself.
|
||||||
// We just need to re-render what changed since the last frame.
|
// Only the changed part of the screen will be updated.
|
||||||
let window = slint::platform::software_renderer::MinimalSoftwareWindow::<1>::new();
|
let window = slint::platform::software_renderer::MinimalSoftwareWindow::new(
|
||||||
|
slint::platform::software_renderer::RepaintBufferType::ReusedBuffer
|
||||||
|
);
|
||||||
|
|
||||||
const DISPLAY_WIDTH: usize = 320;
|
const DISPLAY_WIDTH: usize = 320;
|
||||||
let mut line_buffer = [slint::platform::software_renderer::Rgb565Pixel(0); DISPLAY_WIDTH];
|
let mut line_buffer = [slint::platform::software_renderer::Rgb565Pixel(0); DISPLAY_WIDTH];
|
||||||
|
|
|
@ -36,12 +36,14 @@ pub fn init() {
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct EspBackend {
|
struct EspBackend {
|
||||||
window: RefCell<Option<Rc<slint::platform::software_renderer::MinimalSoftwareWindow<1>>>>,
|
window: RefCell<Option<Rc<slint::platform::software_renderer::MinimalSoftwareWindow>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl slint::platform::Platform for EspBackend {
|
impl slint::platform::Platform for EspBackend {
|
||||||
fn create_window_adapter(&self) -> Rc<dyn slint::platform::WindowAdapter> {
|
fn create_window_adapter(&self) -> Rc<dyn slint::platform::WindowAdapter> {
|
||||||
let window = slint::platform::software_renderer::MinimalSoftwareWindow::new();
|
let window = slint::platform::software_renderer::MinimalSoftwareWindow::new(
|
||||||
|
slint::platform::software_renderer::RepaintBufferType::ReusedBuffer,
|
||||||
|
);
|
||||||
self.window.replace(Some(window.clone()));
|
self.window.replace(Some(window.clone()));
|
||||||
window
|
window
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,12 +33,14 @@ pub fn init() {
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct EspBackend {
|
struct EspBackend {
|
||||||
window: RefCell<Option<Rc<slint::platform::software_renderer::MinimalSoftwareWindow<1>>>>,
|
window: RefCell<Option<Rc<slint::platform::software_renderer::MinimalSoftwareWindow>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl slint::platform::Platform for EspBackend {
|
impl slint::platform::Platform for EspBackend {
|
||||||
fn create_window_adapter(&self) -> Rc<dyn slint::platform::WindowAdapter> {
|
fn create_window_adapter(&self) -> Rc<dyn slint::platform::WindowAdapter> {
|
||||||
let window = slint::platform::software_renderer::MinimalSoftwareWindow::new();
|
let window = slint::platform::software_renderer::MinimalSoftwareWindow::new(
|
||||||
|
slint::platform::software_renderer::RepaintBufferType::ReusedBuffer,
|
||||||
|
);
|
||||||
self.window.replace(Some(window.clone()));
|
self.window.replace(Some(window.clone()));
|
||||||
window
|
window
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,12 +58,13 @@ pub fn init() {
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct PicoBackend {
|
struct PicoBackend {
|
||||||
window: RefCell<Option<Rc<renderer::MinimalSoftwareWindow<1>>>>,
|
window: RefCell<Option<Rc<renderer::MinimalSoftwareWindow>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl slint::platform::Platform for PicoBackend {
|
impl slint::platform::Platform for PicoBackend {
|
||||||
fn create_window_adapter(&self) -> Rc<dyn slint::platform::WindowAdapter> {
|
fn create_window_adapter(&self) -> Rc<dyn slint::platform::WindowAdapter> {
|
||||||
let window = renderer::MinimalSoftwareWindow::new();
|
let window =
|
||||||
|
renderer::MinimalSoftwareWindow::new(renderer::RepaintBufferType::ReusedBuffer);
|
||||||
self.window.replace(Some(window.clone()));
|
self.window.replace(Some(window.clone()));
|
||||||
window
|
window
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,14 +40,15 @@ pub fn init() {
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct StmBackend {
|
struct StmBackend {
|
||||||
window: core::cell::RefCell<
|
window:
|
||||||
Option<Rc<slint::platform::software_renderer::MinimalSoftwareWindow<2>>>,
|
core::cell::RefCell<Option<Rc<slint::platform::software_renderer::MinimalSoftwareWindow>>>,
|
||||||
>,
|
|
||||||
timer: once_cell::unsync::OnceCell<hal::timer::Timer<pac::TIM2>>,
|
timer: once_cell::unsync::OnceCell<hal::timer::Timer<pac::TIM2>>,
|
||||||
}
|
}
|
||||||
impl slint::platform::Platform for StmBackend {
|
impl slint::platform::Platform for StmBackend {
|
||||||
fn create_window_adapter(&self) -> Rc<dyn slint::platform::WindowAdapter> {
|
fn create_window_adapter(&self) -> Rc<dyn slint::platform::WindowAdapter> {
|
||||||
let window = slint::platform::software_renderer::MinimalSoftwareWindow::new();
|
let window = slint::platform::software_renderer::MinimalSoftwareWindow::new(
|
||||||
|
slint::platform::software_renderer::RepaintBufferType::SwappedBuffers,
|
||||||
|
);
|
||||||
self.window.replace(Some(window.clone()));
|
self.window.replace(Some(window.clone()));
|
||||||
window
|
window
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,7 +73,7 @@ cfg_if::cfg_if! {
|
||||||
} else if #[cfg(enable_skia_renderer)] {
|
} else if #[cfg(enable_skia_renderer)] {
|
||||||
type DefaultRenderer = renderer::skia::SkiaRenderer;
|
type DefaultRenderer = renderer::skia::SkiaRenderer;
|
||||||
} else if #[cfg(feature = "renderer-winit-software")] {
|
} else if #[cfg(feature = "renderer-winit-software")] {
|
||||||
type DefaultRenderer = renderer::sw::WinitSoftwareRenderer<0>;
|
type DefaultRenderer = renderer::sw::WinitSoftwareRenderer;
|
||||||
} else {
|
} else {
|
||||||
compile_error!("Please select a feature to build with the winit backend: `renderer-winit-femtovg`, `renderer-winit-skia`, `renderer-winit-skia-opengl` or `renderer-winit-software`");
|
compile_error!("Please select a feature to build with the winit backend: `renderer-winit-femtovg`, `renderer-winit-skia`, `renderer-winit-skia-opengl` or `renderer-winit-software`");
|
||||||
}
|
}
|
||||||
|
@ -102,7 +102,7 @@ impl Backend {
|
||||||
Some("skia") => window_factory_fn::<renderer::skia::SkiaRenderer>,
|
Some("skia") => window_factory_fn::<renderer::skia::SkiaRenderer>,
|
||||||
#[cfg(feature = "renderer-winit-software")]
|
#[cfg(feature = "renderer-winit-software")]
|
||||||
Some("sw") | Some("software") => {
|
Some("sw") | Some("software") => {
|
||||||
window_factory_fn::<renderer::sw::WinitSoftwareRenderer<0>>
|
window_factory_fn::<renderer::sw::WinitSoftwareRenderer>
|
||||||
}
|
}
|
||||||
None => window_factory_fn::<DefaultRenderer>,
|
None => window_factory_fn::<DefaultRenderer>,
|
||||||
Some(renderer_name) => {
|
Some(renderer_name) => {
|
||||||
|
|
|
@ -10,19 +10,20 @@ use i_slint_core::window::WindowAdapter;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::{Rc, Weak};
|
use std::rc::{Rc, Weak};
|
||||||
|
|
||||||
pub struct WinitSoftwareRenderer<const MAX_BUFFER_AGE: usize> {
|
pub struct WinitSoftwareRenderer {
|
||||||
renderer: SoftwareRenderer<MAX_BUFFER_AGE>,
|
renderer: SoftwareRenderer,
|
||||||
canvas: RefCell<Option<softbuffer::GraphicsContext>>,
|
canvas: RefCell<Option<softbuffer::GraphicsContext>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<const MAX_BUFFER_AGE: usize> super::WinitCompatibleRenderer
|
impl super::WinitCompatibleRenderer for WinitSoftwareRenderer {
|
||||||
for WinitSoftwareRenderer<MAX_BUFFER_AGE>
|
|
||||||
{
|
|
||||||
const NAME: &'static str = "Software";
|
const NAME: &'static str = "Software";
|
||||||
|
|
||||||
fn new(window_adapter_weak: &Weak<dyn WindowAdapter>) -> Self {
|
fn new(window_adapter_weak: &Weak<dyn WindowAdapter>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
renderer: SoftwareRenderer::new(window_adapter_weak.clone()),
|
renderer: SoftwareRenderer::new(
|
||||||
|
i_slint_core::software_renderer::RepaintBufferType::NewBuffer,
|
||||||
|
window_adapter_weak.clone(),
|
||||||
|
),
|
||||||
canvas: Default::default(),
|
canvas: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,6 +40,24 @@ type PhysicalPoint = euclid::Point2D<i16, PhysicalPx>;
|
||||||
|
|
||||||
type DirtyRegion = PhysicalRect;
|
type DirtyRegion = PhysicalRect;
|
||||||
|
|
||||||
|
/// This enum describes which parts of the buffer passed to the [`SoftwareRenderer`] may be re-used to speed up painting.
|
||||||
|
#[derive(PartialEq, Eq, Debug, Clone, Default)]
|
||||||
|
pub enum RepaintBufferType {
|
||||||
|
#[default]
|
||||||
|
/// The full window is always redrawn. No attempt at partial rendering will be made.
|
||||||
|
NewBuffer,
|
||||||
|
/// Only redraw the parts that have changed since the previous call to render().
|
||||||
|
///
|
||||||
|
/// This variant assumes that the same buffer is passed on every call to render() and
|
||||||
|
/// that it still contains the previously rendered frame.
|
||||||
|
ReusedBuffer,
|
||||||
|
|
||||||
|
/// Redraw the part that have changed since the last two frames were drawn.
|
||||||
|
///
|
||||||
|
/// This is used when using double buffering and swapping of the buffers.
|
||||||
|
SwappedBuffers,
|
||||||
|
}
|
||||||
|
|
||||||
/// This trait defines a bi-directional interface between Slint and your code to send lines to your screen, when using
|
/// This trait defines a bi-directional interface between Slint and your code to send lines to your screen, when using
|
||||||
/// the [`SoftwareRenderer::render_by_line`] function.
|
/// the [`SoftwareRenderer::render_by_line`] function.
|
||||||
///
|
///
|
||||||
|
@ -76,43 +94,34 @@ pub trait LineBufferProvider {
|
||||||
/// 2. Using [`render_by_line()`](Self::render()) to render the window line by line. This
|
/// 2. Using [`render_by_line()`](Self::render()) to render the window line by line. This
|
||||||
/// is only useful if the device does not have enough memory to render the whole window
|
/// is only useful if the device does not have enough memory to render the whole window
|
||||||
/// in one single buffer
|
/// in one single buffer
|
||||||
///
|
pub struct SoftwareRenderer {
|
||||||
/// ### `MAX_BUFFER_AGE`
|
|
||||||
///
|
|
||||||
/// The `MAX_BUFFER_AGE` parameter specifies how many buffers are being re-used.
|
|
||||||
/// This means that the buffer passed to the render functions still contains a rendering of
|
|
||||||
/// the window that was refreshed as least that amount of frame ago.
|
|
||||||
/// It will impact how much of the screen needs to be redrawn.
|
|
||||||
///
|
|
||||||
/// Typical value can be:
|
|
||||||
/// - **0:** No attempt at tracking dirty items will be made. The full screen is always redrawn.
|
|
||||||
/// - **1:** Only redraw the parts that have changed since the previous call to render.
|
|
||||||
/// This is assuming that the same buffer is passed on every call to render.
|
|
||||||
/// - **2:** Redraw the part that have changed during the two last frames.
|
|
||||||
/// This is assuming double buffering and swapping of the buffers.
|
|
||||||
pub struct SoftwareRenderer<const MAX_BUFFER_AGE: usize> {
|
|
||||||
partial_cache: RefCell<crate::item_rendering::PartialRenderingCache>,
|
partial_cache: RefCell<crate::item_rendering::PartialRenderingCache>,
|
||||||
|
repaint_buffer_type: RepaintBufferType,
|
||||||
/// This is the area which we are going to redraw in the next frame, no matter if the items are dirty or not
|
/// This is the area which we are going to redraw in the next frame, no matter if the items are dirty or not
|
||||||
force_dirty: Cell<crate::item_rendering::DirtyRegion>,
|
force_dirty: Cell<crate::item_rendering::DirtyRegion>,
|
||||||
/// This is the area which was dirty on the previous frames, in case we do double buffering
|
/// This is the area which was dirty on the previous frame.
|
||||||
///
|
/// Only used if repaint_buffer_type == RepaintBufferType::SwappedBuffers
|
||||||
/// We really only need MAX_BUFFER_AGE - 1 but that's not allowed because we cannot do operations with
|
prev_frame_dirty: Cell<DirtyRegion>,
|
||||||
/// generic parameters
|
|
||||||
prev_frame_dirty: [Cell<DirtyRegion>; MAX_BUFFER_AGE],
|
|
||||||
window: Weak<dyn crate::window::WindowAdapter>,
|
window: Weak<dyn crate::window::WindowAdapter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<const MAX_BUFFER_AGE: usize> SoftwareRenderer<MAX_BUFFER_AGE> {
|
impl SoftwareRenderer {
|
||||||
/// Create a new Renderer for a given window.
|
/// Create a new Renderer for a given window.
|
||||||
///
|
///
|
||||||
|
/// The `repaint_buffer_type` parameter specify what kind of buffer are passed to [`Self::render`]
|
||||||
|
///
|
||||||
/// The `window` parameter can be coming from [`Rc::new_cyclic()`](alloc::rc::Rc::new_cyclic())
|
/// The `window` parameter can be coming from [`Rc::new_cyclic()`](alloc::rc::Rc::new_cyclic())
|
||||||
/// since the `WindowAdapter` most likely own the Renderer
|
/// since the `WindowAdapter` most likely own the Renderer
|
||||||
pub fn new(window: Weak<dyn crate::window::WindowAdapter>) -> Self {
|
pub fn new(
|
||||||
|
repaint_buffer_type: RepaintBufferType,
|
||||||
|
window: Weak<dyn crate::window::WindowAdapter>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
window: window.clone(),
|
window: window.clone(),
|
||||||
|
repaint_buffer_type,
|
||||||
partial_cache: Default::default(),
|
partial_cache: Default::default(),
|
||||||
force_dirty: Default::default(),
|
force_dirty: Default::default(),
|
||||||
prev_frame_dirty: [DirtyRegion::default(); MAX_BUFFER_AGE].map(|x| x.into()),
|
prev_frame_dirty: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -123,20 +132,14 @@ impl<const MAX_BUFFER_AGE: usize> SoftwareRenderer<MAX_BUFFER_AGE> {
|
||||||
dirty_region: DirtyRegion,
|
dirty_region: DirtyRegion,
|
||||||
screen_size: PhysicalSize,
|
screen_size: PhysicalSize,
|
||||||
) -> DirtyRegion {
|
) -> DirtyRegion {
|
||||||
if MAX_BUFFER_AGE == 0 {
|
match self.repaint_buffer_type {
|
||||||
PhysicalRect { origin: euclid::point2(0, 0), size: screen_size }
|
RepaintBufferType::NewBuffer => {
|
||||||
} else if MAX_BUFFER_AGE == 1 {
|
PhysicalRect { origin: euclid::point2(0, 0), size: screen_size }
|
||||||
dirty_region
|
}
|
||||||
} else if MAX_BUFFER_AGE == 2 {
|
RepaintBufferType::ReusedBuffer => dirty_region,
|
||||||
dirty_region.union(&self.prev_frame_dirty[0].replace(dirty_region))
|
RepaintBufferType::SwappedBuffers => {
|
||||||
} else {
|
dirty_region.union(&self.prev_frame_dirty.replace(dirty_region))
|
||||||
let mut prev = dirty_region;
|
|
||||||
let mut union = dirty_region;
|
|
||||||
for x in self.prev_frame_dirty.iter().skip(1) {
|
|
||||||
prev = x.replace(prev);
|
|
||||||
union = union.union(&prev);
|
|
||||||
}
|
}
|
||||||
union
|
|
||||||
}
|
}
|
||||||
.intersection(&PhysicalRect { origin: euclid::point2(0, 0), size: screen_size })
|
.intersection(&PhysicalRect { origin: euclid::point2(0, 0), size: screen_size })
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
@ -149,7 +152,7 @@ impl<const MAX_BUFFER_AGE: usize> SoftwareRenderer<MAX_BUFFER_AGE> {
|
||||||
/// be rendered. (eg: the previous dirty region in case of double buffering)
|
/// be rendered. (eg: the previous dirty region in case of double buffering)
|
||||||
///
|
///
|
||||||
/// returns the dirty region for this frame (not including the extra_draw_region)
|
/// returns the dirty region for this frame (not including the extra_draw_region)
|
||||||
pub fn render(&self, buffer: &mut [impl TargetPixel], buffer_stride: usize) {
|
pub fn render(&self, buffer: &mut [impl TargetPixel], pixel_stride: usize) {
|
||||||
let window = self.window.upgrade().expect("render() called on a destroyed Window");
|
let window = self.window.upgrade().expect("render() called on a destroyed Window");
|
||||||
let window_inner = WindowInner::from_pub(window.window());
|
let window_inner = WindowInner::from_pub(window.window());
|
||||||
let factor = ScaleFactor::new(window_inner.scale_factor());
|
let factor = ScaleFactor::new(window_inner.scale_factor());
|
||||||
|
@ -163,16 +166,13 @@ impl<const MAX_BUFFER_AGE: usize> SoftwareRenderer<MAX_BUFFER_AGE> {
|
||||||
window_item.background(),
|
window_item.background(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(euclid::size2(pixel_stride as _, (buffer.len() / pixel_stride) as _), Brush::default())
|
||||||
euclid::size2(buffer_stride as _, (buffer.len() / buffer_stride) as _),
|
|
||||||
Brush::default(),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
let buffer_renderer = SceneBuilder::new(
|
let buffer_renderer = SceneBuilder::new(
|
||||||
size,
|
size,
|
||||||
factor,
|
factor,
|
||||||
window_inner,
|
window_inner,
|
||||||
RenderToBuffer { buffer, stride: buffer_stride },
|
RenderToBuffer { buffer, stride: pixel_stride },
|
||||||
);
|
);
|
||||||
let mut renderer = crate::item_rendering::PartialRenderer::new(
|
let mut renderer = crate::item_rendering::PartialRenderer::new(
|
||||||
&self.partial_cache,
|
&self.partial_cache,
|
||||||
|
@ -221,7 +221,7 @@ impl<const MAX_BUFFER_AGE: usize> SoftwareRenderer<MAX_BUFFER_AGE> {
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use i_slint_core::software_renderer::{LineBufferProvider, SoftwareRenderer, Rgb565Pixel};
|
/// # use i_slint_core::software_renderer::{LineBufferProvider, SoftwareRenderer, Rgb565Pixel};
|
||||||
/// # fn xxx<'a>(the_frame_buffer: &'a mut [Rgb565Pixel], display_width: usize, renderer: &SoftwareRenderer<0>) {
|
/// # fn xxx<'a>(the_frame_buffer: &'a mut [Rgb565Pixel], display_width: usize, renderer: &SoftwareRenderer) {
|
||||||
/// struct FrameBuffer<'a>{ frame_buffer: &'a mut [Rgb565Pixel], stride: usize }
|
/// struct FrameBuffer<'a>{ frame_buffer: &'a mut [Rgb565Pixel], stride: usize }
|
||||||
/// impl<'a> LineBufferProvider for FrameBuffer<'a> {
|
/// impl<'a> LineBufferProvider for FrameBuffer<'a> {
|
||||||
/// type TargetPixel = Rgb565Pixel;
|
/// type TargetPixel = Rgb565Pixel;
|
||||||
|
@ -263,7 +263,7 @@ impl<const MAX_BUFFER_AGE: usize> SoftwareRenderer<MAX_BUFFER_AGE> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
impl<const MAX_BUFFER_AGE: usize> Renderer for SoftwareRenderer<MAX_BUFFER_AGE> {
|
impl Renderer for SoftwareRenderer {
|
||||||
fn text_size(
|
fn text_size(
|
||||||
&self,
|
&self,
|
||||||
font_request: crate::graphics::FontRequest,
|
font_request: crate::graphics::FontRequest,
|
||||||
|
@ -328,11 +328,11 @@ impl<const MAX_BUFFER_AGE: usize> Renderer for SoftwareRenderer<MAX_BUFFER_AGE>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_window_frame_by_line<const MAX_BUFFER_AGE: usize>(
|
fn render_window_frame_by_line(
|
||||||
window: &WindowInner,
|
window: &WindowInner,
|
||||||
background: Brush,
|
background: Brush,
|
||||||
size: PhysicalSize,
|
size: PhysicalSize,
|
||||||
renderer: &SoftwareRenderer<MAX_BUFFER_AGE>,
|
renderer: &SoftwareRenderer,
|
||||||
mut line_buffer: impl LineBufferProvider,
|
mut line_buffer: impl LineBufferProvider,
|
||||||
) {
|
) {
|
||||||
let mut scene = prepare_scene(window, size, renderer);
|
let mut scene = prepare_scene(window, size, renderer);
|
||||||
|
@ -749,10 +749,10 @@ struct GradientCommand {
|
||||||
bottom_clip: PhysicalLength,
|
bottom_clip: PhysicalLength,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_scene<const MAX_BUFFER_AGE: usize>(
|
fn prepare_scene(
|
||||||
window: &WindowInner,
|
window: &WindowInner,
|
||||||
size: PhysicalSize,
|
size: PhysicalSize,
|
||||||
software_renderer: &SoftwareRenderer<MAX_BUFFER_AGE>,
|
software_renderer: &SoftwareRenderer,
|
||||||
) -> Scene {
|
) -> Scene {
|
||||||
let factor = ScaleFactor::new(window.scale_factor());
|
let factor = ScaleFactor::new(window.scale_factor());
|
||||||
let prepare_scene = SceneBuilder::new(size, factor, window, PrepareScene::default());
|
let prepare_scene = SceneBuilder::new(size, factor, window, PrepareScene::default());
|
||||||
|
@ -1640,21 +1640,20 @@ impl<'a, T: ProcessScene> crate::item_rendering::ItemRenderer for SceneBuilder<'
|
||||||
|
|
||||||
/// This is a minimal adapter for a Window that doesn't have any other feature than rendering
|
/// This is a minimal adapter for a Window that doesn't have any other feature than rendering
|
||||||
/// using the software renderer.
|
/// using the software renderer.
|
||||||
///
|
pub struct MinimalSoftwareWindow {
|
||||||
/// The [`MAX_BUFFER_AGE`](SoftwareRenderer#max_buffer_age) generic parameter is forwarded to
|
|
||||||
/// the [`SoftwareRenderer`]
|
|
||||||
pub struct MinimalSoftwareWindow<const MAX_BUFFER_AGE: usize> {
|
|
||||||
window: Window,
|
window: Window,
|
||||||
renderer: SoftwareRenderer<MAX_BUFFER_AGE>,
|
renderer: SoftwareRenderer,
|
||||||
needs_redraw: Cell<bool>,
|
needs_redraw: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<const MAX_BUFFER_AGE: usize> MinimalSoftwareWindow<MAX_BUFFER_AGE> {
|
impl MinimalSoftwareWindow {
|
||||||
/// Instantiate a new MinimalWindowAdaptor
|
/// Instantiate a new MinimalWindowAdaptor
|
||||||
pub fn new() -> Rc<Self> {
|
///
|
||||||
|
/// The `repaint_buffer_type` parameter specify what kind of buffer are passed to the [`SoftwareRenderer`]
|
||||||
|
pub fn new(repaint_buffer_type: RepaintBufferType) -> Rc<Self> {
|
||||||
Rc::new_cyclic(|w: &Weak<Self>| Self {
|
Rc::new_cyclic(|w: &Weak<Self>| Self {
|
||||||
window: Window::new(w.clone()),
|
window: Window::new(w.clone()),
|
||||||
renderer: SoftwareRenderer::new(w.clone()),
|
renderer: SoftwareRenderer::new(repaint_buffer_type, w.clone()),
|
||||||
needs_redraw: Default::default(),
|
needs_redraw: Default::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -1665,10 +1664,7 @@ impl<const MAX_BUFFER_AGE: usize> MinimalSoftwareWindow<MAX_BUFFER_AGE> {
|
||||||
/// in that callback.
|
/// in that callback.
|
||||||
///
|
///
|
||||||
/// Return true if something was redrawn.
|
/// Return true if something was redrawn.
|
||||||
pub fn draw_if_needed(
|
pub fn draw_if_needed(&self, render_callback: impl FnOnce(&SoftwareRenderer)) -> bool {
|
||||||
&self,
|
|
||||||
render_callback: impl FnOnce(&SoftwareRenderer<MAX_BUFFER_AGE>),
|
|
||||||
) -> bool {
|
|
||||||
if self.needs_redraw.replace(false) {
|
if self.needs_redraw.replace(false) {
|
||||||
render_callback(&self.renderer);
|
render_callback(&self.renderer);
|
||||||
true
|
true
|
||||||
|
@ -1678,9 +1674,7 @@ impl<const MAX_BUFFER_AGE: usize> MinimalSoftwareWindow<MAX_BUFFER_AGE> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<const MAX_BUFFER_AGE: usize> crate::window::WindowAdapterSealed
|
impl crate::window::WindowAdapterSealed for MinimalSoftwareWindow {
|
||||||
for MinimalSoftwareWindow<MAX_BUFFER_AGE>
|
|
||||||
{
|
|
||||||
fn request_redraw(&self) {
|
fn request_redraw(&self) {
|
||||||
self.needs_redraw.set(true);
|
self.needs_redraw.set(true);
|
||||||
}
|
}
|
||||||
|
@ -1696,13 +1690,13 @@ impl<const MAX_BUFFER_AGE: usize> crate::window::WindowAdapterSealed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<const MAX_BUFFER_AGE: usize> WindowAdapter for MinimalSoftwareWindow<MAX_BUFFER_AGE> {
|
impl WindowAdapter for MinimalSoftwareWindow {
|
||||||
fn window(&self) -> &Window {
|
fn window(&self) -> &Window {
|
||||||
&self.window
|
&self.window
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<const MAX_BUFFER_AGE: usize> core::ops::Deref for MinimalSoftwareWindow<MAX_BUFFER_AGE> {
|
impl core::ops::Deref for MinimalSoftwareWindow {
|
||||||
type Target = Window;
|
type Target = Window;
|
||||||
fn deref(&self) -> &Self::Target {
|
fn deref(&self) -> &Self::Target {
|
||||||
&self.window
|
&self.window
|
||||||
|
|
|
@ -19,7 +19,7 @@ use i_slint_core::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct SwrTestingBackend {
|
pub struct SwrTestingBackend {
|
||||||
window: Rc<MinimalSoftwareWindow<1>>,
|
window: Rc<MinimalSoftwareWindow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl i_slint_core::platform::Platform for SwrTestingBackend {
|
impl i_slint_core::platform::Platform for SwrTestingBackend {
|
||||||
|
@ -32,8 +32,10 @@ impl i_slint_core::platform::Platform for SwrTestingBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_swr() -> Rc<MinimalSoftwareWindow<1>> {
|
pub fn init_swr() -> Rc<MinimalSoftwareWindow> {
|
||||||
let window = MinimalSoftwareWindow::new();
|
let window = MinimalSoftwareWindow::new(
|
||||||
|
i_slint_core::software_renderer::RepaintBufferType::ReusedBuffer,
|
||||||
|
);
|
||||||
|
|
||||||
i_slint_core::platform::set_platform(Box::new(SwrTestingBackend { window: window.clone() }))
|
i_slint_core::platform::set_platform(Box::new(SwrTestingBackend { window: window.clone() }))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
@ -52,7 +54,7 @@ pub fn image_buffer(path: &str) -> Result<SharedPixelBuffer<Rgb8Pixel>, image::I
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn screenshot(window: Rc<MinimalSoftwareWindow<1>>) -> SharedPixelBuffer<Rgb8Pixel> {
|
pub fn screenshot(window: Rc<MinimalSoftwareWindow>) -> SharedPixelBuffer<Rgb8Pixel> {
|
||||||
let size = window.size();
|
let size = window.size();
|
||||||
let width = size.width;
|
let width = size.width;
|
||||||
let height = size.height;
|
let height = size.height;
|
||||||
|
@ -184,14 +186,14 @@ fn compare_images(
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn assert_with_render(path: &str, window: Rc<MinimalSoftwareWindow<1>>) {
|
pub fn assert_with_render(path: &str, window: Rc<MinimalSoftwareWindow>) {
|
||||||
let rendering = screenshot(window);
|
let rendering = screenshot(window);
|
||||||
if let Err(reason) = compare_images(path, &rendering) {
|
if let Err(reason) = compare_images(path, &rendering) {
|
||||||
panic!("Image comparison failure for {path}: {reason}");
|
panic!("Image comparison failure for {path}: {reason}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn assert_with_render_by_line(path: &str, window: Rc<MinimalSoftwareWindow<1>>) {
|
pub fn assert_with_render_by_line(path: &str, window: Rc<MinimalSoftwareWindow>) {
|
||||||
let s = window.size();
|
let s = window.size();
|
||||||
let mut rendering = SharedPixelBuffer::<Rgb8Pixel>::new(s.width, s.height);
|
let mut rendering = SharedPixelBuffer::<Rgb8Pixel>::new(s.width, s.height);
|
||||||
|
|
||||||
|
@ -213,7 +215,7 @@ pub fn assert_with_render_by_line(path: &str, window: Rc<MinimalSoftwareWindow<1
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn screenshot_render_by_line(
|
pub fn screenshot_render_by_line(
|
||||||
window: Rc<MinimalSoftwareWindow<1>>,
|
window: Rc<MinimalSoftwareWindow>,
|
||||||
region: Option<IntRect>,
|
region: Option<IntRect>,
|
||||||
buffer: &mut SharedPixelBuffer<Rgb8Pixel>,
|
buffer: &mut SharedPixelBuffer<Rgb8Pixel>,
|
||||||
) {
|
) {
|
||||||
|
@ -236,7 +238,7 @@ pub fn screenshot_render_by_line(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_screenshot(path: &str, window: Rc<MinimalSoftwareWindow<1>>) {
|
pub fn save_screenshot(path: &str, window: Rc<MinimalSoftwareWindow>) {
|
||||||
let buffer = screenshot(window.clone());
|
let buffer = screenshot(window.clone());
|
||||||
image::save_buffer(
|
image::save_buffer(
|
||||||
path,
|
path,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue