mirror of
https://github.com/juce-framework/JUCE.git
synced 2026-01-10 23:44:24 +00:00
CGMetalRenderer: Avoid glitching when resizing views
This commit is contained in:
parent
fe09902e83
commit
9d50ab6c59
3 changed files with 215 additions and 122 deletions
|
|
@ -30,15 +30,14 @@ namespace juce
|
|||
{
|
||||
|
||||
//==============================================================================
|
||||
template <typename ViewType>
|
||||
class CoreGraphicsMetalLayerRenderer
|
||||
{
|
||||
public:
|
||||
//==============================================================================
|
||||
static auto create (ViewType* view, bool isOpaque)
|
||||
static auto create()
|
||||
{
|
||||
ObjCObjectHandle<id<MTLDevice>> device { MTLCreateSystemDefaultDevice() };
|
||||
return rawToUniquePtr (device != nullptr ? new CoreGraphicsMetalLayerRenderer (device, view, isOpaque)
|
||||
return rawToUniquePtr (device != nullptr ? new CoreGraphicsMetalLayerRenderer (device)
|
||||
: nullptr);
|
||||
}
|
||||
|
||||
|
|
@ -51,48 +50,15 @@ public:
|
|||
}
|
||||
}
|
||||
|
||||
void attach (ViewType* view, bool isOpaque)
|
||||
{
|
||||
#if JUCE_MAC
|
||||
view.wantsLayer = YES;
|
||||
view.layerContentsPlacement = NSViewLayerContentsPlacementTopLeft;
|
||||
view.layer = [CAMetalLayer layer];
|
||||
#endif
|
||||
|
||||
auto layer = (CAMetalLayer*) view.layer;
|
||||
|
||||
layer.device = device.get();
|
||||
layer.framebufferOnly = NO;
|
||||
layer.pixelFormat = MTLPixelFormatBGRA8Unorm_sRGB;
|
||||
layer.opaque = isOpaque;
|
||||
layer.allowsNextDrawableTimeout = NO;
|
||||
|
||||
attachedView = view;
|
||||
doSynchronousRender = true;
|
||||
}
|
||||
|
||||
void detach()
|
||||
{
|
||||
#if JUCE_MAC
|
||||
attachedView.wantsLayer = NO;
|
||||
attachedView.layer = nil;
|
||||
#endif
|
||||
|
||||
attachedView = nullptr;
|
||||
}
|
||||
|
||||
bool isAttachedToView (ViewType* view) const
|
||||
{
|
||||
return view == attachedView && attachedView != nullptr;
|
||||
}
|
||||
|
||||
/* Returns any regions that weren't redrawn, and which should be retried next frame. */
|
||||
template <typename Callback>
|
||||
bool drawRectangleList (ViewType* view,
|
||||
float scaleFactor,
|
||||
Callback&& drawRectWithContext,
|
||||
const RectangleList<float>& dirtyRegions)
|
||||
[[nodiscard]] RectangleList<float> drawRectangleList (CAMetalLayer* layer,
|
||||
float scaleFactor,
|
||||
Callback&& drawRectWithContext,
|
||||
RectangleList<float> dirtyRegions,
|
||||
const bool renderSync)
|
||||
{
|
||||
auto layer = (CAMetalLayer*) view.layer;
|
||||
layer.presentsWithTransaction = renderSync;
|
||||
|
||||
if (memoryBlitCommandBuffer != nullptr)
|
||||
{
|
||||
|
|
@ -104,7 +70,7 @@ public:
|
|||
case MTLCommandBufferStatusScheduled:
|
||||
// If we haven't finished blitting the CPU texture to the GPU then
|
||||
// report that we have been unable to draw anything.
|
||||
return false;
|
||||
return dirtyRegions;
|
||||
case MTLCommandBufferStatusCompleted:
|
||||
case MTLCommandBufferStatusError:
|
||||
break;
|
||||
|
|
@ -112,14 +78,15 @@ public:
|
|||
}
|
||||
|
||||
layer.contentsScale = scaleFactor;
|
||||
const auto drawableSizeTansform = CGAffineTransformMakeScale (layer.contentsScale,
|
||||
layer.contentsScale);
|
||||
const auto transformedFrameSize = CGSizeApplyAffineTransform (view.frame.size, drawableSizeTansform);
|
||||
|
||||
const auto drawableSizeTransform = CGAffineTransformMakeScale (layer.contentsScale, layer.contentsScale);
|
||||
const auto transformedFrameSize = CGSizeApplyAffineTransform (layer.bounds.size, drawableSizeTransform);
|
||||
|
||||
if (resources == nullptr || ! CGSizeEqualToSize (layer.drawableSize, transformedFrameSize))
|
||||
{
|
||||
layer.drawableSize = transformedFrameSize;
|
||||
resources = std::make_unique<Resources> (device.get(), layer);
|
||||
dirtyRegions = convertToRectFloat (layer.bounds);
|
||||
}
|
||||
|
||||
auto gpuTexture = resources->getGpuTexture();
|
||||
|
|
@ -127,7 +94,7 @@ public:
|
|||
if (gpuTexture == nullptr)
|
||||
{
|
||||
jassertfalse;
|
||||
return false;
|
||||
return dirtyRegions;
|
||||
}
|
||||
|
||||
auto cgContext = resources->getCGContext();
|
||||
|
|
@ -165,7 +132,7 @@ public:
|
|||
[blitCommandEncoder endEncoding];
|
||||
};
|
||||
|
||||
if (doSynchronousRender)
|
||||
if (renderSync)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
|
|
@ -174,11 +141,10 @@ public:
|
|||
id<CAMetalDrawable> drawable = [layer nextDrawable];
|
||||
encodeBlit (commandBuffer, sharedTexture, drawable.texture);
|
||||
|
||||
[commandBuffer presentDrawable: drawable];
|
||||
[commandBuffer commit];
|
||||
[commandBuffer waitUntilScheduled];
|
||||
[drawable present];
|
||||
}
|
||||
|
||||
doSynchronousRender = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -218,18 +184,16 @@ public:
|
|||
[memoryBlitCommandBuffer.get() commit];
|
||||
}
|
||||
|
||||
return true;
|
||||
dirtyRegions.clear();
|
||||
return dirtyRegions;
|
||||
}
|
||||
|
||||
private:
|
||||
//==============================================================================
|
||||
CoreGraphicsMetalLayerRenderer (ObjCObjectHandle<id<MTLDevice>> mtlDevice,
|
||||
ViewType* view,
|
||||
bool isOpaque)
|
||||
explicit CoreGraphicsMetalLayerRenderer (ObjCObjectHandle<id<MTLDevice>> mtlDevice)
|
||||
: device (mtlDevice),
|
||||
commandQueue ([device.get() newCommandQueue])
|
||||
{
|
||||
attach (view, isOpaque);
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
|
|
@ -389,9 +353,6 @@ private:
|
|||
};
|
||||
|
||||
//==============================================================================
|
||||
ViewType* attachedView = nullptr;
|
||||
bool doSynchronousRender = false;
|
||||
|
||||
std::unique_ptr<Resources> resources;
|
||||
|
||||
ObjCObjectHandle<id<MTLDevice>> device;
|
||||
|
|
|
|||
|
|
@ -122,7 +122,54 @@ static constexpr int translateVirtualToAsciiKeyCode (int keyCode) noexcept
|
|||
constexpr int extendedKeyModifier = 0x30000;
|
||||
|
||||
//==============================================================================
|
||||
class NSViewComponentPeer : public ComponentPeer
|
||||
class JuceCALayerDelegate : public ObjCClass<NSObject<CALayerDelegate>>
|
||||
{
|
||||
public:
|
||||
struct Callback
|
||||
{
|
||||
virtual ~Callback() = default;
|
||||
virtual void displayLayer (CALayer*) = 0;
|
||||
};
|
||||
|
||||
static NSObject<CALayerDelegate>* construct (Callback* owner)
|
||||
{
|
||||
static JuceCALayerDelegate cls;
|
||||
auto* result = cls.createInstance();
|
||||
setOwner (result, owner);
|
||||
return result;
|
||||
}
|
||||
|
||||
private:
|
||||
JuceCALayerDelegate()
|
||||
: ObjCClass ("JuceCALayerDelegate_")
|
||||
{
|
||||
addIvar<Callback*> ("owner");
|
||||
|
||||
addMethod (@selector (displayLayer:), [] (id self, SEL, CALayer* layer)
|
||||
{
|
||||
if (auto* owner = getOwner (self))
|
||||
owner->displayLayer (layer);
|
||||
});
|
||||
|
||||
addProtocol (@protocol (CALayerDelegate));
|
||||
|
||||
registerClass();
|
||||
}
|
||||
|
||||
static Callback* getOwner (id self)
|
||||
{
|
||||
return getIvar<Callback*> (self, "owner");
|
||||
}
|
||||
|
||||
static void setOwner (id self, Callback* newOwner)
|
||||
{
|
||||
object_setInstanceVariable (self, "owner", newOwner);
|
||||
}
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
class NSViewComponentPeer : public ComponentPeer,
|
||||
private JuceCALayerDelegate::Callback
|
||||
{
|
||||
public:
|
||||
NSViewComponentPeer (Component& comp, const int windowStyleFlags, NSView* viewToAttachTo)
|
||||
|
|
@ -148,18 +195,37 @@ public:
|
|||
[view setPostsFrameChangedNotifications: YES];
|
||||
|
||||
#if USE_COREGRAPHICS_RENDERING
|
||||
// Creating a metal renderer may fail on some systems.
|
||||
// We need to try creating the renderer before first creating a backing layer
|
||||
// so that we know whether to use a metal layer or the system default layer
|
||||
// (setWantsLayer: YES will call through to makeBackingLayer, where we check
|
||||
// whether metalRenderer is non-null).
|
||||
// The system overwrites the layer delegate set during makeBackingLayer,
|
||||
// so that must be set separately, after the layer has been created and
|
||||
// configured.
|
||||
#if JUCE_COREGRAPHICS_RENDER_WITH_MULTIPLE_PAINT_CALLS
|
||||
if (@available (macOS 10.14, *))
|
||||
metalRenderer = CoreGraphicsMetalLayerRenderer<NSView>::create (view, getComponent().isOpaque());
|
||||
{
|
||||
metalRenderer = CoreGraphicsMetalLayerRenderer::create();
|
||||
layerDelegate.reset (JuceCALayerDelegate::construct (this));
|
||||
}
|
||||
#endif
|
||||
|
||||
if ((windowStyleFlags & ComponentPeer::windowRequiresSynchronousCoreGraphicsRendering) == 0)
|
||||
{
|
||||
if (@available (macOS 10.8, *))
|
||||
{
|
||||
[view setWantsLayer: YES];
|
||||
[[view layer] setDrawsAsynchronously: YES];
|
||||
[view setLayerContentsRedrawPolicy: NSViewLayerContentsRedrawDuringViewResize];
|
||||
[view layer].drawsAsynchronously = YES;
|
||||
}
|
||||
}
|
||||
|
||||
#if JUCE_COREGRAPHICS_RENDER_WITH_MULTIPLE_PAINT_CALLS
|
||||
if (@available (macOS 10.14, *))
|
||||
if (metalRenderer != nullptr)
|
||||
view.layer.delegate = layerDelegate.get();
|
||||
#endif
|
||||
#endif
|
||||
|
||||
if (isSharedWindow)
|
||||
|
|
@ -1009,8 +1075,6 @@ public:
|
|||
// As a workaround for this, we use a RectangleList to do our own coalescing of regions before
|
||||
// asynchronously asking the OS to repaint them.
|
||||
deferredRepaints.add (area.toFloat());
|
||||
const auto frameSize = view.frame.size;
|
||||
boundsWhenRepaintsDeferred = { (float) frameSize.width, (float) frameSize.height };
|
||||
}
|
||||
|
||||
static bool shouldThrottleRepaint()
|
||||
|
|
@ -1044,40 +1108,10 @@ public:
|
|||
const auto frameSize = view.frame.size;
|
||||
const Rectangle currentBounds { (float) frameSize.width, (float) frameSize.height };
|
||||
|
||||
if (boundsWhenRepaintsDeferred != currentBounds)
|
||||
{
|
||||
deferredRepaints = currentBounds;
|
||||
for (auto& i : deferredRepaints)
|
||||
[view setNeedsDisplayInRect: makeNSRect (i)];
|
||||
|
||||
if (metalRenderer != nullptr && metalRenderer->isAttachedToView (view))
|
||||
metalRenderer->detach();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (metalRenderer != nullptr && ! metalRenderer->isAttachedToView (view))
|
||||
metalRenderer->attach (view, getComponent().isOpaque());
|
||||
}
|
||||
|
||||
auto dispatchRectangles = [this]
|
||||
{
|
||||
if (metalRenderer != nullptr && metalRenderer->isAttachedToView (view))
|
||||
{
|
||||
return metalRenderer->drawRectangleList (view,
|
||||
(float) [[view window] backingScaleFactor],
|
||||
[this] (CGContextRef ctx, CGRect r) { drawRectWithContext (ctx, r); },
|
||||
deferredRepaints);
|
||||
}
|
||||
|
||||
for (auto& i : deferredRepaints)
|
||||
[view setNeedsDisplayInRect: makeNSRect (i)];
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (dispatchRectangles())
|
||||
{
|
||||
lastRepaintTime = Time::getMillisecondCounter();
|
||||
deferredRepaints.clear();
|
||||
}
|
||||
lastRepaintTime = Time::getMillisecondCounter();
|
||||
}
|
||||
|
||||
void performAnyPendingRepaintsNow() override
|
||||
|
|
@ -1690,7 +1724,6 @@ public:
|
|||
int startOfMarkedTextInTextInputTarget = 0;
|
||||
|
||||
Rectangle<float> lastSizeBeforeZoom;
|
||||
Rectangle<float> boundsWhenRepaintsDeferred;
|
||||
RectangleList<float> deferredRepaints;
|
||||
uint32 lastRepaintTime;
|
||||
|
||||
|
|
@ -1709,6 +1742,11 @@ public:
|
|||
static inline const auto resignKeySelector = @selector (resignKey:);
|
||||
JUCE_END_IGNORE_WARNINGS_GCC_LIKE
|
||||
|
||||
#if JUCE_COREGRAPHICS_RENDER_WITH_MULTIPLE_PAINT_CALLS
|
||||
std::unique_ptr<CoreGraphicsMetalLayerRenderer> metalRenderer;
|
||||
NSUniquePtr<NSObject<CALayerDelegate>> layerDelegate;
|
||||
#endif
|
||||
|
||||
private:
|
||||
JUCE_DECLARE_WEAK_REFERENCEABLE (NSViewComponentPeer)
|
||||
|
||||
|
|
@ -1935,9 +1973,29 @@ private:
|
|||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
std::unique_ptr<CoreGraphicsMetalLayerRenderer<NSView>> metalRenderer;
|
||||
void displayLayer ([[maybe_unused]] CALayer* layer) override
|
||||
{
|
||||
#if JUCE_COREGRAPHICS_RENDER_WITH_MULTIPLE_PAINT_CALLS
|
||||
if (metalRenderer == nullptr)
|
||||
return;
|
||||
|
||||
const auto scale = [this]
|
||||
{
|
||||
if (auto* viewWindow = [view window])
|
||||
return (float) viewWindow.backingScaleFactor;
|
||||
|
||||
return 1.0f;
|
||||
}();
|
||||
|
||||
deferredRepaints = metalRenderer->drawRectangleList (static_cast<CAMetalLayer*> (layer),
|
||||
scale,
|
||||
[this] (auto&&... args) { drawRectWithContext (args...); },
|
||||
std::move (deferredRepaints),
|
||||
[view inLiveResize]);
|
||||
#endif
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
std::vector<ScopedNotificationCenterObserver> scopedObservers;
|
||||
std::vector<ScopedNotificationCenterObserver> windowObservers;
|
||||
|
||||
|
|
@ -1969,6 +2027,7 @@ struct NSViewComponentPeerWrapper : public Base
|
|||
}
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
struct JuceNSViewClass : public NSViewComponentPeerWrapper<ObjCClass<NSView>>
|
||||
{
|
||||
JuceNSViewClass() : NSViewComponentPeerWrapper ("JUCEView_")
|
||||
|
|
@ -2042,6 +2101,35 @@ struct JuceNSViewClass : public NSViewComponentPeerWrapper<ObjCClass<NSView>>
|
|||
|
||||
addMethod (@selector (acceptsFirstMouse:), [] (id, SEL, NSEvent*) { return YES; });
|
||||
|
||||
#if JUCE_COREGRAPHICS_RENDER_WITH_MULTIPLE_PAINT_CALLS
|
||||
addMethod (@selector (makeBackingLayer), [] (id self, SEL) -> CALayer*
|
||||
{
|
||||
if (auto* owner = getOwner (self))
|
||||
{
|
||||
if (owner->metalRenderer != nullptr)
|
||||
{
|
||||
auto* layer = [CAMetalLayer layer];
|
||||
|
||||
layer.device = MTLCreateSystemDefaultDevice();
|
||||
layer.framebufferOnly = NO;
|
||||
layer.pixelFormat = MTLPixelFormatBGRA8Unorm_sRGB;
|
||||
layer.opaque = getOwner (self)->getComponent().isOpaque();
|
||||
layer.autoresizingMask = kCALayerHeightSizable | kCALayerWidthSizable;
|
||||
layer.needsDisplayOnBoundsChange = YES;
|
||||
layer.drawsAsynchronously = YES;
|
||||
layer.delegate = owner->layerDelegate.get();
|
||||
|
||||
if (@available (macOS 10.13, *))
|
||||
layer.allowsNextDrawableTimeout = NO;
|
||||
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuperclassMessage<CALayer*> (self, @selector (makeBackingLayer));
|
||||
});
|
||||
#endif
|
||||
|
||||
addMethod (@selector (windowWillMiniaturize:), [] (id self, SEL, NSNotification*)
|
||||
{
|
||||
if (auto* p = getOwner (self))
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ struct CADisplayLinkDeleter
|
|||
|
||||
@end
|
||||
|
||||
@interface JuceUIView : UIView
|
||||
@interface JuceUIView : UIView<CALayerDelegate>
|
||||
{
|
||||
@public
|
||||
UIViewComponentPeer* owner;
|
||||
|
|
@ -520,6 +520,12 @@ public:
|
|||
return UIKeyboardTypeDefault;
|
||||
}
|
||||
|
||||
#if JUCE_COREGRAPHICS_RENDER_WITH_MULTIPLE_PAINT_CALLS
|
||||
std::unique_ptr<CoreGraphicsMetalLayerRenderer> metalRenderer;
|
||||
#endif
|
||||
|
||||
RectangleList<float> deferredRepaints;
|
||||
|
||||
private:
|
||||
void appStyleChanged() override
|
||||
{
|
||||
|
|
@ -545,9 +551,6 @@ private:
|
|||
}
|
||||
};
|
||||
|
||||
std::unique_ptr<CoreGraphicsMetalLayerRenderer<UIView>> metalRenderer;
|
||||
RectangleList<float> deferredRepaints;
|
||||
|
||||
//==============================================================================
|
||||
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (UIViewComponentPeer)
|
||||
};
|
||||
|
|
@ -698,6 +701,26 @@ MultiTouchMapper<UITouch*> UIViewComponentPeer::currentTouches;
|
|||
[super initWithFrame: frame];
|
||||
owner = peer;
|
||||
|
||||
#if JUCE_COREGRAPHICS_RENDER_WITH_MULTIPLE_PAINT_CALLS
|
||||
if (@available (iOS 13.0, *))
|
||||
{
|
||||
auto* layer = (CAMetalLayer*) [self layer];
|
||||
layer.device = MTLCreateSystemDefaultDevice();
|
||||
layer.framebufferOnly = NO;
|
||||
layer.pixelFormat = MTLPixelFormatBGRA8Unorm_sRGB;
|
||||
|
||||
if (owner != nullptr)
|
||||
layer.opaque = owner->getComponent().isOpaque();
|
||||
|
||||
layer.presentsWithTransaction = YES;
|
||||
layer.needsDisplayOnBoundsChange = true;
|
||||
layer.presentsWithTransaction = true;
|
||||
layer.delegate = self;
|
||||
|
||||
layer.allowsNextDrawableTimeout = NO;
|
||||
}
|
||||
#endif
|
||||
|
||||
displayLink.reset ([CADisplayLink displayLinkWithTarget: self
|
||||
selector: @selector (displayLinkCallback:)]);
|
||||
[displayLink.get() addToRunLoop: [NSRunLoop mainRunLoop]
|
||||
|
|
@ -750,6 +773,41 @@ MultiTouchMapper<UITouch*> UIViewComponentPeer::currentTouches;
|
|||
owner->displayLinkCallback();
|
||||
}
|
||||
|
||||
#if JUCE_COREGRAPHICS_RENDER_WITH_MULTIPLE_PAINT_CALLS
|
||||
- (CALayer*) makeBackingLayer
|
||||
{
|
||||
auto* layer = [CAMetalLayer layer];
|
||||
|
||||
layer.device = MTLCreateSystemDefaultDevice();
|
||||
layer.framebufferOnly = NO;
|
||||
layer.pixelFormat = MTLPixelFormatBGRA8Unorm_sRGB;
|
||||
|
||||
if (owner != nullptr)
|
||||
layer.opaque = owner->getComponent().isOpaque();
|
||||
|
||||
layer.presentsWithTransaction = YES;
|
||||
layer.needsDisplayOnBoundsChange = true;
|
||||
layer.presentsWithTransaction = true;
|
||||
layer.delegate = self;
|
||||
|
||||
layer.allowsNextDrawableTimeout = NO;
|
||||
|
||||
return layer;
|
||||
}
|
||||
|
||||
- (void) displayLayer: (CALayer*) layer
|
||||
{
|
||||
if (owner != nullptr)
|
||||
{
|
||||
owner->deferredRepaints = owner->metalRenderer->drawRectangleList (static_cast<CAMetalLayer*> (layer),
|
||||
(float) [self contentScaleFactor],
|
||||
[self] (auto&&... args) { owner->drawRectWithContext (args...); },
|
||||
std::move (owner->deferredRepaints),
|
||||
false);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
//==============================================================================
|
||||
- (void) drawRect: (CGRect) r
|
||||
{
|
||||
|
|
@ -1680,7 +1738,7 @@ UIViewComponentPeer::UIViewComponentPeer (Component& comp, int windowStyleFlags,
|
|||
#if JUCE_COREGRAPHICS_RENDER_WITH_MULTIPLE_PAINT_CALLS
|
||||
if (@available (iOS 13, *))
|
||||
{
|
||||
metalRenderer = CoreGraphicsMetalLayerRenderer<UIView>::create (view, comp.isOpaque());
|
||||
metalRenderer = CoreGraphicsMetalLayerRenderer::create();
|
||||
jassert (metalRenderer != nullptr);
|
||||
}
|
||||
#endif
|
||||
|
|
@ -2138,22 +2196,8 @@ void UIViewComponentPeer::displayLinkCallback()
|
|||
if (deferredRepaints.isEmpty())
|
||||
return;
|
||||
|
||||
auto dispatchRectangles = [this] ()
|
||||
{
|
||||
if (metalRenderer != nullptr)
|
||||
return metalRenderer->drawRectangleList (view,
|
||||
(float) view.contentScaleFactor,
|
||||
[this] (CGContextRef ctx, CGRect r) { drawRectWithContext (ctx, r); },
|
||||
deferredRepaints);
|
||||
|
||||
for (const auto& r : deferredRepaints)
|
||||
[view setNeedsDisplayInRect: convertToCGRect (r)];
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (dispatchRectangles())
|
||||
deferredRepaints.clear();
|
||||
for (const auto& r : deferredRepaints)
|
||||
[view setNeedsDisplayInRect: convertToCGRect (r)];
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue