Replace Old Widgets
This commit is contained in:
parent
03da5e41d6
commit
c41dc20132
81 changed files with 388 additions and 278 deletions
149
Widgets/private/layouts/BasicLayout.cpp
Normal file
149
Widgets/private/layouts/BasicLayout.cpp
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
|
||||
#include "BasicLayout.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace tp;
|
||||
|
||||
|
||||
void BasicLayout::pickRect(bool vertical) {
|
||||
auto current = getArea();
|
||||
auto children = getChildrenEnclosure();
|
||||
auto parent = getParentEnclosure();
|
||||
|
||||
children.shrinkFromCenter(-mLayoutMargin, true);
|
||||
|
||||
RectF newArea;
|
||||
if (vertical) {
|
||||
auto rangeY = pickRange(current.getRangeY(), children.getRangeY(), parent.getRangeY(), true);
|
||||
newArea = RectF(current.getRangeX(), rangeY);
|
||||
} else {
|
||||
auto rangeX = pickRange(current.getRangeX(), children.getRangeX(), parent.getRangeX(), false);
|
||||
newArea = RectF(rangeX, current.getRangeY());
|
||||
}
|
||||
|
||||
setArea(newArea);
|
||||
}
|
||||
|
||||
void BasicLayout::clampRect() {
|
||||
DEBUG_ASSERT(0)
|
||||
|
||||
auto current = getArea();
|
||||
auto children = getChildrenEnclosure();
|
||||
auto parent = getParentEnclosure();
|
||||
|
||||
auto rangeX = clampRange(current.getRangeX(), children.getRangeX(), parent.getRangeX(), false);
|
||||
auto rangeY = clampRange(current.getRangeY(), children.getRangeY(), parent.getRangeY(), true);
|
||||
|
||||
setArea(RectF(rangeX, rangeY));
|
||||
}
|
||||
|
||||
void BasicLayout::updateLayout(bool vertical) {
|
||||
if (children().empty()) return;
|
||||
|
||||
if (vertical) {
|
||||
switch (mLayoutPolicy) {
|
||||
case LayoutPolicy::Vertical: adjustLayout(true); break;
|
||||
case LayoutPolicy::Passive:
|
||||
case LayoutPolicy::Horizontal: break;
|
||||
}
|
||||
} else {
|
||||
switch (mLayoutPolicy) {
|
||||
case LayoutPolicy::Horizontal: adjustLayout(false); break;
|
||||
case LayoutPolicy::Passive:
|
||||
case LayoutPolicy::Vertical: break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
halnf BasicLayout::changeChildSize(tp::Widget* widget, halnf diff, bool vertical) {
|
||||
auto prevSize = widget->getLayout()->getArea().size[vertical];
|
||||
{
|
||||
auto area = widget->getLayout()->getArea();
|
||||
area.size[vertical] += diff;
|
||||
widget->setAreaCache(area);
|
||||
|
||||
widget->getLayout()->clampMinMaxSize();
|
||||
}
|
||||
auto newSize = widget->getLayout()->getArea().size[vertical];
|
||||
return newSize - prevSize;
|
||||
}
|
||||
|
||||
void BasicLayout::adjustLayout(bool vertical) {
|
||||
std::vector<std::pair<Widget*, bool>> contributors;
|
||||
Vec2F contentSize = { 0, 0 };
|
||||
Vec2F availableSize = getArea().size;
|
||||
|
||||
for (auto child : children()) {
|
||||
child->getLayout()->pickRect(vertical);
|
||||
|
||||
if (child->getLayout()->getSizePolicy()[vertical] == SizePolicy::Expanding) {
|
||||
contributors.emplace_back( child, true );
|
||||
|
||||
auto area = child->getLayout()->getArea();
|
||||
area.size[vertical] = 0;
|
||||
child->setAreaCache(area);
|
||||
child->getLayout()->clampMinMaxSize();
|
||||
}
|
||||
|
||||
contentSize += child->getLayout()->getArea().size;
|
||||
}
|
||||
|
||||
availableSize -= mLayoutGap * ((halnf) children().size() - 1) + mLayoutMargin * 2;
|
||||
|
||||
auto diff = availableSize - contentSize;
|
||||
|
||||
// expand or contract as much as possible
|
||||
while (!contributors.empty() && (diff[vertical] != 0)) {
|
||||
auto quota = diff / (halnf) contributors.size();
|
||||
|
||||
for (auto& contributor : contributors) {
|
||||
if (!contributor.second) continue;
|
||||
|
||||
// contributor.first->endAnimations();
|
||||
auto contribution = changeChildSize(contributor.first, quota[vertical], vertical);
|
||||
|
||||
if (contribution == 0) {
|
||||
contributor.second = false;
|
||||
}
|
||||
|
||||
diff[vertical] -= contribution;
|
||||
}
|
||||
|
||||
contributors.erase(
|
||||
std::remove_if(contributors.begin(), contributors.end(), [](auto node) { return !node.second; }),
|
||||
contributors.end()
|
||||
);
|
||||
}
|
||||
|
||||
// set opposite direction size
|
||||
for (auto child : children()) {
|
||||
// if (child->getLayout()->getSizePolicy()[!vertical] != SizePolicy::Minimal) {
|
||||
auto area = child->getLayout()->getArea();
|
||||
area.size[!vertical] = getArea().size[!vertical] - mLayoutMargin * 2;
|
||||
child->setAreaCache(area);
|
||||
// }
|
||||
}
|
||||
|
||||
// set pos
|
||||
halnf iterPos = mLayoutMargin;
|
||||
for (auto child : children()) {
|
||||
auto area = child->getLayout()->getArea();
|
||||
|
||||
area.pos[vertical] = iterPos;
|
||||
area.pos[!vertical] = mLayoutMargin;
|
||||
|
||||
iterPos += area.size[vertical] + mLayoutGap;
|
||||
child->setAreaCache(area);
|
||||
|
||||
// child->updateAnimations();
|
||||
// child->triggerWidgetUpdate("layout changed");
|
||||
}
|
||||
}
|
||||
|
||||
RectF BasicLayout::getAvailableChildArea() const {
|
||||
auto out = WidgetLayout::getAvailableChildArea();
|
||||
return out.shrinkFromCenter(mLayoutMargin, true);
|
||||
}
|
||||
290
Widgets/private/layouts/DockLayout.cpp
Normal file
290
Widgets/private/layouts/DockLayout.cpp
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
#include "DockLayout.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
DockLayout::DockLayout(Widget* widget) :
|
||||
WidgetLayout(widget) {
|
||||
mSideWidgets[0].side = LEFT;
|
||||
mSideWidgets[1].side = TOP;
|
||||
mSideWidgets[2].side = RIGHT;
|
||||
mSideWidgets[3].side = BOTTOM;
|
||||
}
|
||||
|
||||
bool DockLayout::setCenterWidget(Widget* widget) {
|
||||
if (mCenterWidget) return false;
|
||||
mCenterWidget = widget;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DockLayout::dockWidget(Widget* widget, Side side) {
|
||||
if (sideExists(side) || side == Side::NONE) return false;
|
||||
|
||||
auto& sideWidget = mSideWidgets[side];
|
||||
sideWidget.widget = widget;
|
||||
|
||||
for (auto& order : mSideWidgets) {
|
||||
if (order.order == -1) {
|
||||
order.order = side;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sideWidget.hidden = false;
|
||||
sideWidget.areaBeforeDocking = widget->getArea();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DockLayout::undockWidget(Side side) {
|
||||
if (!sideExists(side) || (side == Side::NONE)) return false;
|
||||
|
||||
bool removed = false;
|
||||
for (ualni i = 0; i < 3; i++) {
|
||||
if (mSideWidgets[i].order == side) {
|
||||
removed = true;
|
||||
}
|
||||
if (removed) {
|
||||
swapV(mSideWidgets[i].order, mSideWidgets[i + 1].order);
|
||||
}
|
||||
}
|
||||
mSideWidgets[3].order = -1;
|
||||
mSideWidgets[side].widget = nullptr;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void DockLayout::toggleWidgetVisibility(Side side) {
|
||||
DEBUG_ASSERT(sideExists(side))
|
||||
mSideWidgets[side].hidden = !mSideWidgets[side].hidden;
|
||||
}
|
||||
|
||||
void DockLayout::calculateSideAreas() {
|
||||
auto startArea = getArea().relative();
|
||||
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
const auto side = sideWidget.order;
|
||||
|
||||
if (side == -1) break;
|
||||
if (!isSideVisible(Side(side))) continue;
|
||||
|
||||
bool vertical = side == TOP || side == BOTTOM;
|
||||
bool opposite = side == BOTTOM || side == RIGHT;
|
||||
|
||||
auto& sideSize = mSideWidgets[side].absoluteSize;
|
||||
|
||||
auto factor = sideSize / startArea.size[vertical];
|
||||
if (opposite) factor = factor * -1 + 1;
|
||||
auto& area = mSideWidgets[side].area;
|
||||
|
||||
if (!vertical) {
|
||||
const auto first = startArea.splitByFactorHL(factor);
|
||||
const auto second = startArea.splitByFactorHR(factor);
|
||||
area = side == LEFT ? first : second;
|
||||
startArea = side == LEFT ? second : first;
|
||||
} else {
|
||||
const auto first = startArea.splitByFactorVT(factor);
|
||||
const auto second = startArea.splitByFactorVB(factor);
|
||||
area = side == TOP ? first : second;
|
||||
startArea = side == TOP ? second : first;
|
||||
}
|
||||
|
||||
area = area.shrink(mPadding);
|
||||
}
|
||||
|
||||
mCenterArea = startArea.shrink(mPadding);
|
||||
}
|
||||
|
||||
void DockLayout::calculateResizeHandles() {
|
||||
RectF rec;
|
||||
RectF area = getArea().relative();
|
||||
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
auto& sideSize = sideWidget.absoluteSize;
|
||||
auto& resizeHandle = sideWidget.resizeHandle;
|
||||
|
||||
if (resizeHandle.end < mSideSizePadding * 2) {
|
||||
sideSize = resizeHandle.end / 2.f;
|
||||
} else {
|
||||
sideSize = clamp(sideSize, resizeHandle.start + mSideSizePadding, resizeHandle.end - mSideSizePadding);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSideVisible(LEFT)) {
|
||||
auto& side = mSideWidgets[LEFT];
|
||||
rec = { side.area.p4(), { mPadding * 2, side.area.size.y } };
|
||||
side.resizeHandle.area = rec;
|
||||
side.resizeHandle.start = 0;
|
||||
side.resizeHandle.end = mCenterArea.p3().x;
|
||||
}
|
||||
if (isSideVisible(RIGHT)) {
|
||||
auto& side = mSideWidgets[RIGHT];
|
||||
rec = { side.area.p1(), { mPadding * 2, side.area.size.y } };
|
||||
rec.x -= mPadding * 2;
|
||||
side.resizeHandle.area = rec;
|
||||
side.resizeHandle.start = 0;
|
||||
side.resizeHandle.end = (area.p3() - mCenterArea.p1()).x;
|
||||
}
|
||||
if (isSideVisible(TOP)) {
|
||||
auto& side = mSideWidgets[TOP];
|
||||
rec = { side.area.p2(), { side.area.size.x, mPadding * 2 } };
|
||||
side.resizeHandle.area = rec;
|
||||
side.resizeHandle.start = 0;
|
||||
side.resizeHandle.end = mCenterArea.p2().y;
|
||||
}
|
||||
if (isSideVisible(BOTTOM)) {
|
||||
auto& side = mSideWidgets[BOTTOM];
|
||||
rec = { side.area.p1(), { side.area.size.x, mPadding * 2 } };
|
||||
rec.y -= mPadding * 2;
|
||||
side.resizeHandle.area = rec;
|
||||
side.resizeHandle.start = 0;
|
||||
side.resizeHandle.end = (area.p3() - mCenterArea.p1()).y;
|
||||
}
|
||||
}
|
||||
|
||||
void DockLayout::updateChildSideWidgets() {
|
||||
for (ualni i = 0; i < 4; i++) {
|
||||
if (!sideExists(Side(i))) continue;
|
||||
auto widget = mSideWidgets[i].widget;
|
||||
|
||||
if (!isSideVisible(Side(i))) {
|
||||
// FIXME : widget->mEnable = false;
|
||||
} else {
|
||||
widget->setAreaCache(mSideWidgets[i].area);
|
||||
// FIXME : widget->mEnable = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mCenterWidget) mCenterWidget->setAreaCache(mCenterArea);
|
||||
}
|
||||
|
||||
void DockLayout::calculateHeaderAreas() {
|
||||
for (ualni i = 0; i < 4; i++) {
|
||||
if (!isSideVisible(Side(i))) continue;
|
||||
auto& area = mSideWidgets[i].area;
|
||||
const auto factor = mHeaderSize / area.size.y;
|
||||
mSideWidgets[i].headerArea = area.splitByFactorVT(factor);
|
||||
area = area.splitByFactorVB(factor);
|
||||
|
||||
mSideWidgets[i].headerArea.size.y -= mPadding;
|
||||
}
|
||||
}
|
||||
|
||||
ualni DockLayout::getVisibleSidesSize() const {
|
||||
ualni out = 0;
|
||||
for (ualni i = 0; i < 4; i++) {
|
||||
if (isSideVisible(Side(i))) out++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
auto DockLayout::getSideFromWidget(Widget* widget) -> Side {
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (sideWidget.widget == widget) return sideWidget.side;
|
||||
}
|
||||
return DockLayout::NONE;
|
||||
}
|
||||
|
||||
void DockLayout::updateLayout(bool vertical) {
|
||||
for (auto child : children()) {
|
||||
child->getLayout()->pickRect(vertical);
|
||||
}
|
||||
|
||||
if (vertical) return;
|
||||
adjustChildrenRect();
|
||||
}
|
||||
|
||||
void DockLayout::adjustChildrenRect() {
|
||||
calculateSideAreas();
|
||||
calculateResizeHandles();
|
||||
updateChildSideWidgets();
|
||||
}
|
||||
|
||||
void DockLayout::updatePreviewSide(const Vec2F& pointer) {
|
||||
const auto handleSize = min(mCenterArea.size.x, mCenterArea.size.y) * mHandleFactor;
|
||||
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (sideWidget.widget) continue;
|
||||
|
||||
switch (sideWidget.side) {
|
||||
case TOP: sideWidget.area = mCenterArea.splitByFactorVT(mHandleSplitFactor); break;
|
||||
case BOTTOM: sideWidget.area = mCenterArea.splitByFactorVB(1 - mHandleSplitFactor); break;
|
||||
case LEFT: sideWidget.area = mCenterArea.splitByFactorHL(mHandleSplitFactor); break;
|
||||
case RIGHT: sideWidget.area = mCenterArea.splitByFactorHR(1 - mHandleSplitFactor); break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
sideWidget.area = sideWidget.area.shrink(mPadding * 2);
|
||||
sideWidget.previewHandleArea = sideWidget.area.getSizedFromCenter({ handleSize, handleSize });
|
||||
}
|
||||
|
||||
mPreviewArea = {};
|
||||
mPreviewSide = NONE;
|
||||
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (sideWidget.widget) continue;
|
||||
|
||||
if (sideWidget.previewHandleArea.isInside(pointer)) {
|
||||
mPreviewArea = sideWidget.area;
|
||||
mPreviewSide = sideWidget.side;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DockLayout::Side DockLayout::getPreviewSide() {
|
||||
return mPreviewSide;
|
||||
}
|
||||
|
||||
void DockLayout::updateResizeHover(const Vec2F& pointer) {
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
sideWidget.resizeHandle.hover = sideWidget.resizeHandle.area.isInside(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
void DockLayout::updateResize(const Vec2F& pointerDelta) {
|
||||
if (!mResizing) return;
|
||||
|
||||
// do the resizing
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (!sideWidget.resizeHandle.active) continue;
|
||||
halnf delta = pointerDelta[(sideWidget.side == TOP || sideWidget.side == BOTTOM)];
|
||||
if (sideWidget.side == BOTTOM || sideWidget.side == RIGHT) {
|
||||
delta *= -1;
|
||||
}
|
||||
sideWidget.absoluteSize += delta;
|
||||
}
|
||||
}
|
||||
|
||||
bool DockLayout::startResize(const Vec2F& pointer) {
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (sideWidget.resizeHandle.hover) {
|
||||
sideWidget.resizeHandle.active = true;
|
||||
mResizing = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void DockLayout::endResize() {
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
sideWidget.resizeHandle.active = false;
|
||||
}
|
||||
mResizing = false;
|
||||
}
|
||||
|
||||
bool DockLayout::isSideVisible(DockLayout::Side side) const {
|
||||
return sideExists(side) && !mSideWidgets[side].hidden;
|
||||
}
|
||||
|
||||
bool DockLayout::sideExists(DockLayout::Side side) const {
|
||||
return mSideWidgets[side].widget;
|
||||
}
|
||||
|
||||
const std::vector<Widget*>& DockLayout::getDockedWidgets() {
|
||||
mDockedWidgets.resize(5);
|
||||
for (auto i = 0; i < 4; i++) {
|
||||
mDockedWidgets[i] = mSideWidgets[i].widget;
|
||||
}
|
||||
mDockedWidgets[4] = mCenterWidget;
|
||||
return mDockedWidgets;
|
||||
}
|
||||
52
Widgets/private/layouts/FloatingLayout.cpp
Normal file
52
Widgets/private/layouts/FloatingLayout.cpp
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
#include "FloatingLayout.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void FloatingLayout::pickRect(bool vertical) {
|
||||
if (mIsFloating) {
|
||||
auto area = getArea();
|
||||
|
||||
if (mIsResizing) {
|
||||
mPointerCurrent.clamp(mMinSize, mMaxSize);
|
||||
|
||||
area.size[vertical] = (mPointerCurrent + mHandleSize / 2.f)[vertical];
|
||||
|
||||
for (auto child : children()) {
|
||||
child->triggerWidgetUpdate("floating menu resized");
|
||||
}
|
||||
|
||||
} else if (mIsFloating) {
|
||||
area.pos[vertical] += (mPointerCurrent - mPointerStart)[vertical];
|
||||
}
|
||||
|
||||
setArea(area);
|
||||
}
|
||||
|
||||
clampMinMaxSize();
|
||||
}
|
||||
|
||||
RectF FloatingLayout::resizeHandleRect() {
|
||||
auto area = getAnimatedArea().relative();
|
||||
area.pos = area.p3() - mHandleSize;
|
||||
area.size = mHandleSize;
|
||||
area.shrink(mHandlePadding);
|
||||
return area;
|
||||
}
|
||||
|
||||
void FloatingLayout::startAction(const Vec2F& pointer) {
|
||||
mPointerStart = pointer;
|
||||
|
||||
mIsFloating = true;
|
||||
if (resizeHandleRect().isInside(mPointerStart)) {
|
||||
mIsResizing = true;
|
||||
}
|
||||
}
|
||||
|
||||
void FloatingLayout::updateAction(const Vec2F& pointer) {
|
||||
mPointerCurrent = pointer;
|
||||
}
|
||||
|
||||
void FloatingLayout::endAction() {
|
||||
mIsResizing = mIsFloating = false;
|
||||
}
|
||||
29
Widgets/private/layouts/OverlayLayout.cpp
Normal file
29
Widgets/private/layouts/OverlayLayout.cpp
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
|
||||
#include "OverlayLayout.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void OverlayLayout::updateLayout(bool vertical) {
|
||||
if (vertical) return;
|
||||
|
||||
if (children().empty()) return;
|
||||
|
||||
for (auto child : children()) {
|
||||
child->getLayout()->setArea(getArea().relative());
|
||||
}
|
||||
|
||||
/*
|
||||
if (children().empty()) return;
|
||||
|
||||
auto first = children().front();
|
||||
|
||||
first->getLayout()->setArea(getArea().relative());
|
||||
|
||||
for (auto child : children()) {
|
||||
if (child == first) continue;
|
||||
child->getLayout()->setArea(getArea().relative());
|
||||
}
|
||||
*/
|
||||
}
|
||||
53
Widgets/private/layouts/ScrollableLayout.cpp
Normal file
53
Widgets/private/layouts/ScrollableLayout.cpp
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#include "ScrollableLayout.hpp"
|
||||
|
||||
// fixme : this dependency should be removed
|
||||
#include "ScrollableWidget.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void ScrollableLayout::updateLayout(bool vertical) {
|
||||
if (vertical) return;
|
||||
|
||||
// TODO : make better interface to get scroller and content widget
|
||||
if (children().size() != 2) return;
|
||||
|
||||
auto scroller = dynamic_cast<ScrollableBarWidget*>(children().front());
|
||||
auto content = children().back();
|
||||
|
||||
if (!scroller || !content) return;
|
||||
|
||||
updateWidgetRects(getArea().relative(), content, scroller);
|
||||
}
|
||||
|
||||
void ScrollableLayout::updateWidgetRects(const RectF& area, Widget* content, ScrollableBarWidget* scroller) const {
|
||||
bool dir = scroller->getDirection();
|
||||
|
||||
// update content
|
||||
// auto contentItems = content->getLayout()->getChildrenEnclosure();
|
||||
// content->getLayout()->setArea(contentItems.shrinkFromCenter(-10, true));
|
||||
content->getLayout()->pickRect(dir);
|
||||
|
||||
auto sizeFactor = (area.size[dir] / content->getLayout()->getArea().size[dir]);
|
||||
auto splitFactor = 1 - ((sizeFactor > 1.f) ? 0 : mScrollerSize / area.size[!dir]);
|
||||
|
||||
auto holderArea = area.splitByFactorHL(splitFactor);
|
||||
auto contentArea = content->getLayout()->getArea();
|
||||
auto scrollerArea = area.splitByFactorHR(splitFactor);
|
||||
|
||||
scroller->updateSizeFactor(sizeFactor);
|
||||
|
||||
contentArea.pos[dir] = -scroller->getPosFactor() * contentArea.size[dir];
|
||||
contentArea.size[!dir] = holderArea.size[!dir];
|
||||
contentArea.pos[!dir] = 0;
|
||||
|
||||
scroller->getLayout()->setArea(scrollerArea);
|
||||
content->getLayout()->setArea(contentArea);
|
||||
}
|
||||
|
||||
RectF ScrollableLayout::getAvailableChildArea() const {
|
||||
auto out = getArea();
|
||||
// dont constrain on scroll axis
|
||||
out.pos = -FLT_MAX / 4;
|
||||
out.size = FLT_MAX / 2;
|
||||
return out;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue