зеркало из https://github.com/mozilla/gecko-dev.git
Bug 893977. Support repeating gradients in the CoreGraphics backend. r=mattwoordow
CoreGraphics doesn't support repeating gradients natively so we have to manually repeat them. This change missing support for interpolating a stop for the center if it doesn't line up correctly. That will come later.
This commit is contained in:
Родитель
65937fa722
Коммит
7fa5ac69af
|
@ -8,8 +8,11 @@
|
|||
#include "ScaledFontMac.h"
|
||||
#include "Tools.h"
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
#include "QuartzSupport.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
//CG_EXTERN void CGContextSetCompositeOperation (CGContextRef, PrivateCGCompositeMode);
|
||||
|
||||
// A private API that Cairo has been using for a long time
|
||||
|
@ -313,37 +316,51 @@ class GradientStopsCG : public GradientStops
|
|||
//XXX: The skia backend uses a vector and passes in aNumStops. It should do better
|
||||
GradientStopsCG(GradientStop* aStops, uint32_t aNumStops, ExtendMode aExtendMode)
|
||||
{
|
||||
//XXX: do the stops need to be in any particular order?
|
||||
// what should we do about the color space here? we certainly shouldn't be
|
||||
// recreating it all the time
|
||||
std::vector<CGFloat> colors;
|
||||
std::vector<CGFloat> offsets;
|
||||
colors.reserve(aNumStops*4);
|
||||
offsets.reserve(aNumStops);
|
||||
mExtend = aExtendMode;
|
||||
if (aExtendMode == EXTEND_CLAMP) {
|
||||
//XXX: do the stops need to be in any particular order?
|
||||
// what should we do about the color space here? we certainly shouldn't be
|
||||
// recreating it all the time
|
||||
std::vector<CGFloat> colors;
|
||||
std::vector<CGFloat> offsets;
|
||||
colors.reserve(aNumStops*4);
|
||||
offsets.reserve(aNumStops);
|
||||
|
||||
for (uint32_t i = 0; i < aNumStops; i++) {
|
||||
colors.push_back(aStops[i].color.r);
|
||||
colors.push_back(aStops[i].color.g);
|
||||
colors.push_back(aStops[i].color.b);
|
||||
colors.push_back(aStops[i].color.a);
|
||||
for (uint32_t i = 0; i < aNumStops; i++) {
|
||||
colors.push_back(aStops[i].color.r);
|
||||
colors.push_back(aStops[i].color.g);
|
||||
colors.push_back(aStops[i].color.b);
|
||||
colors.push_back(aStops[i].color.a);
|
||||
|
||||
offsets.push_back(aStops[i].offset);
|
||||
offsets.push_back(aStops[i].offset);
|
||||
}
|
||||
|
||||
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
|
||||
mGradient = CGGradientCreateWithColorComponents(colorSpace,
|
||||
&colors.front(),
|
||||
&offsets.front(),
|
||||
aNumStops);
|
||||
CGColorSpaceRelease(colorSpace);
|
||||
} else {
|
||||
mGradient = nullptr;
|
||||
mStops.reserve(aNumStops);
|
||||
for (uint32_t i = 0; i < aNumStops; i++) {
|
||||
mStops.push_back(aStops[i]);
|
||||
}
|
||||
}
|
||||
|
||||
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
|
||||
mGradient = CGGradientCreateWithColorComponents(colorSpace,
|
||||
&colors.front(),
|
||||
&offsets.front(),
|
||||
aNumStops);
|
||||
CGColorSpaceRelease(colorSpace);
|
||||
}
|
||||
virtual ~GradientStopsCG() {
|
||||
CGGradientRelease(mGradient);
|
||||
if (mGradient)
|
||||
CGGradientRelease(mGradient);
|
||||
}
|
||||
// Will always report BACKEND_COREGRAPHICS, but it is compatible
|
||||
// with BACKEND_COREGRAPHICS_ACCELERATED
|
||||
BackendType GetBackendType() const { return BACKEND_COREGRAPHICS; }
|
||||
// XXX this should be a union
|
||||
CGGradientRef mGradient;
|
||||
std::vector<GradientStop> mStops;
|
||||
ExtendMode mExtend;
|
||||
};
|
||||
|
||||
TemporaryRef<GradientStops>
|
||||
|
@ -353,35 +370,183 @@ DrawTargetCG::CreateGradientStops(GradientStop *aStops, uint32_t aNumStops,
|
|||
return new GradientStopsCG(aStops, aNumStops, aExtendMode);
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
DrawGradient(CGContextRef cg, const Pattern &aPattern)
|
||||
DrawLinearRepeatingGradient(CGContextRef cg, const LinearGradientPattern &aPattern, const CGRect &aExtents)
|
||||
{
|
||||
GradientStopsCG *stops = static_cast<GradientStopsCG*>(aPattern.mStops.get());
|
||||
CGPoint startPoint = { aPattern.mBegin.x, aPattern.mBegin.y };
|
||||
CGPoint endPoint = { aPattern.mEnd.x, aPattern.mEnd.y };
|
||||
|
||||
// extend the gradient line in multiples of the existing length in both
|
||||
// directions until it crosses an edge of the extents box.
|
||||
double xDiff = aPattern.mEnd.x - aPattern.mBegin.x;
|
||||
double yDiff = aPattern.mEnd.y - aPattern.mBegin.y;
|
||||
|
||||
int repeatCount = 1;
|
||||
// if we don't have a line then we can't extend it
|
||||
if (xDiff || yDiff) {
|
||||
while (startPoint.x > aExtents.origin.x
|
||||
&& startPoint.y > aExtents.origin.y
|
||||
&& startPoint.x < (aExtents.origin.x+aExtents.size.width)
|
||||
&& startPoint.y < (aExtents.origin.y+aExtents.size.height))
|
||||
{
|
||||
startPoint.x -= xDiff;
|
||||
startPoint.y -= yDiff;
|
||||
repeatCount++;
|
||||
}
|
||||
while (endPoint.x > aExtents.origin.x
|
||||
&& endPoint.y > aExtents.origin.y
|
||||
&& endPoint.x < (aExtents.origin.x+aExtents.size.width)
|
||||
&& endPoint.y < (aExtents.origin.y+aExtents.size.height))
|
||||
{
|
||||
endPoint.x += xDiff;
|
||||
endPoint.y += yDiff;
|
||||
repeatCount++;
|
||||
}
|
||||
}
|
||||
|
||||
double scale = 1./repeatCount;
|
||||
|
||||
std::vector<CGFloat> colors;
|
||||
std::vector<CGFloat> offsets;
|
||||
colors.reserve(stops->mStops.size()*repeatCount*4);
|
||||
offsets.reserve(stops->mStops.size()*repeatCount);
|
||||
|
||||
for (int j = 0; j < repeatCount; j++) {
|
||||
for (uint32_t i = 0; i < stops->mStops.size(); i++) {
|
||||
colors.push_back(stops->mStops[i].color.r);
|
||||
colors.push_back(stops->mStops[i].color.g);
|
||||
colors.push_back(stops->mStops[i].color.b);
|
||||
colors.push_back(stops->mStops[i].color.a);
|
||||
|
||||
offsets.push_back((stops->mStops[i].offset + j)*scale);
|
||||
}
|
||||
}
|
||||
|
||||
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
|
||||
CGGradientRef gradient = CGGradientCreateWithColorComponents(colorSpace,
|
||||
&colors.front(),
|
||||
&offsets.front(),
|
||||
repeatCount*stops->mStops.size());
|
||||
CGColorSpaceRelease(colorSpace);
|
||||
|
||||
CGContextDrawLinearGradient(cg, gradient, startPoint, endPoint,
|
||||
kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
|
||||
CGGradientRelease(gradient);
|
||||
}
|
||||
|
||||
static CGPoint CGRectTopLeft(CGRect a)
|
||||
{ return a.origin; }
|
||||
static CGPoint CGRectBottomLeft(CGRect a)
|
||||
{ return CGPointMake(a.origin.x, a.origin.y + a.size.height); }
|
||||
static CGPoint CGRectTopRight(CGRect a)
|
||||
{ return CGPointMake(a.origin.x + a.size.width, a.origin.y); }
|
||||
static CGPoint CGRectBottomRight(CGRect a)
|
||||
{ return CGPointMake(a.origin.x + a.size.width, a.origin.y + a.size.height); }
|
||||
|
||||
static CGFloat
|
||||
CGPointDistance(CGPoint a, CGPoint b)
|
||||
{
|
||||
return hypot(a.x-b.x, a.y-b.y);
|
||||
}
|
||||
|
||||
static void
|
||||
DrawRadialRepeatingGradient(CGContextRef cg, const RadialGradientPattern &aPattern, const CGRect &aExtents)
|
||||
{
|
||||
GradientStopsCG *stops = static_cast<GradientStopsCG*>(aPattern.mStops.get());
|
||||
CGPoint startCenter = { aPattern.mCenter1.x, aPattern.mCenter1.y };
|
||||
CGFloat startRadius = aPattern.mRadius1;
|
||||
CGPoint endCenter = { aPattern.mCenter2.x, aPattern.mCenter2.y };
|
||||
CGFloat endRadius = aPattern.mRadius2;
|
||||
|
||||
// find the maximum distance from endCenter to a corner of aExtents
|
||||
CGFloat minimumEndRadius = endRadius;
|
||||
minimumEndRadius = max(minimumEndRadius, CGPointDistance(endCenter, CGRectTopLeft(aExtents)));
|
||||
minimumEndRadius = max(minimumEndRadius, CGPointDistance(endCenter, CGRectBottomLeft(aExtents)));
|
||||
minimumEndRadius = max(minimumEndRadius, CGPointDistance(endCenter, CGRectTopRight(aExtents)));
|
||||
minimumEndRadius = max(minimumEndRadius, CGPointDistance(endCenter, CGRectBottomRight(aExtents)));
|
||||
|
||||
CGFloat length = endRadius - startRadius;
|
||||
int repeatCount = 1;
|
||||
while (endRadius < minimumEndRadius) {
|
||||
endRadius += length;
|
||||
repeatCount++;
|
||||
}
|
||||
|
||||
while (startRadius-length >= 0) {
|
||||
startRadius -= length;
|
||||
repeatCount++;
|
||||
}
|
||||
|
||||
double scale = 1./repeatCount;
|
||||
|
||||
std::vector<CGFloat> colors;
|
||||
std::vector<CGFloat> offsets;
|
||||
colors.reserve(stops->mStops.size()*repeatCount*4);
|
||||
offsets.reserve(stops->mStops.size()*repeatCount);
|
||||
for (int j = 0; j < repeatCount; j++) {
|
||||
for (uint32_t i = 0; i < stops->mStops.size(); i++) {
|
||||
colors.push_back(stops->mStops[i].color.r);
|
||||
colors.push_back(stops->mStops[i].color.g);
|
||||
colors.push_back(stops->mStops[i].color.b);
|
||||
colors.push_back(stops->mStops[i].color.a);
|
||||
|
||||
offsets.push_back((stops->mStops[i].offset + j)*scale);
|
||||
}
|
||||
}
|
||||
|
||||
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
|
||||
CGGradientRef gradient = CGGradientCreateWithColorComponents(colorSpace,
|
||||
&colors.front(),
|
||||
&offsets.front(),
|
||||
repeatCount*stops->mStops.size());
|
||||
CGColorSpaceRelease(colorSpace);
|
||||
|
||||
//XXX: are there degenerate radial gradients that we should avoid drawing?
|
||||
CGContextDrawRadialGradient(cg, gradient, startCenter, startRadius, endCenter, endRadius,
|
||||
kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
|
||||
CGGradientRelease(gradient);
|
||||
}
|
||||
|
||||
static void
|
||||
DrawGradient(CGContextRef cg, const Pattern &aPattern, const CGRect &aExtents)
|
||||
{
|
||||
if (aPattern.GetType() == PATTERN_LINEAR_GRADIENT) {
|
||||
const LinearGradientPattern& pat = static_cast<const LinearGradientPattern&>(aPattern);
|
||||
GradientStopsCG *stops = static_cast<GradientStopsCG*>(pat.mStops.get());
|
||||
// XXX: we should take the m out of the properties of LinearGradientPatterns
|
||||
CGPoint startPoint = { pat.mBegin.x, pat.mBegin.y };
|
||||
CGPoint endPoint = { pat.mEnd.x, pat.mEnd.y };
|
||||
if (stops->mExtend == EXTEND_CLAMP) {
|
||||
|
||||
// Canvas spec states that we should avoid drawing degenerate gradients (XXX: should this be in common code?)
|
||||
//if (startPoint.x == endPoint.x && startPoint.y == endPoint.y)
|
||||
// return;
|
||||
// XXX: we should take the m out of the properties of LinearGradientPatterns
|
||||
CGPoint startPoint = { pat.mBegin.x, pat.mBegin.y };
|
||||
CGPoint endPoint = { pat.mEnd.x, pat.mEnd.y };
|
||||
|
||||
CGContextDrawLinearGradient(cg, stops->mGradient, startPoint, endPoint,
|
||||
kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
|
||||
// Canvas spec states that we should avoid drawing degenerate gradients (XXX: should this be in common code?)
|
||||
//if (startPoint.x == endPoint.x && startPoint.y == endPoint.y)
|
||||
// return;
|
||||
|
||||
CGContextDrawLinearGradient(cg, stops->mGradient, startPoint, endPoint,
|
||||
kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
|
||||
} else if (stops->mExtend == EXTEND_REPEAT) {
|
||||
DrawLinearRepeatingGradient(cg, pat, aExtents);
|
||||
}
|
||||
} else if (aPattern.GetType() == PATTERN_RADIAL_GRADIENT) {
|
||||
const RadialGradientPattern& pat = static_cast<const RadialGradientPattern&>(aPattern);
|
||||
GradientStopsCG *stops = static_cast<GradientStopsCG*>(pat.mStops.get());
|
||||
if (stops->mExtend == EXTEND_CLAMP) {
|
||||
|
||||
// XXX: we should take the m out of the properties of RadialGradientPatterns
|
||||
CGPoint startCenter = { pat.mCenter1.x, pat.mCenter1.y };
|
||||
CGFloat startRadius = pat.mRadius1;
|
||||
CGPoint endCenter = { pat.mCenter2.x, pat.mCenter2.y };
|
||||
CGFloat endRadius = pat.mRadius2;
|
||||
// XXX: we should take the m out of the properties of RadialGradientPatterns
|
||||
CGPoint startCenter = { pat.mCenter1.x, pat.mCenter1.y };
|
||||
CGFloat startRadius = pat.mRadius1;
|
||||
CGPoint endCenter = { pat.mCenter2.x, pat.mCenter2.y };
|
||||
CGFloat endRadius = pat.mRadius2;
|
||||
|
||||
//XXX: are there degenerate radial gradients that we should avoid drawing?
|
||||
CGContextDrawRadialGradient(cg, stops->mGradient, startCenter, startRadius, endCenter, endRadius,
|
||||
kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
|
||||
//XXX: are there degenerate radial gradients that we should avoid drawing?
|
||||
CGContextDrawRadialGradient(cg, stops->mGradient, startCenter, startRadius, endCenter, endRadius,
|
||||
kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
|
||||
} else if (stops->mExtend == EXTEND_REPEAT) {
|
||||
DrawRadialRepeatingGradient(cg, pat, aExtents);
|
||||
}
|
||||
} else {
|
||||
assert(0);
|
||||
}
|
||||
|
@ -554,7 +719,7 @@ DrawTargetCG::MaskSurface(const Pattern &aSource,
|
|||
if (isGradient(aSource)) {
|
||||
// we shouldn't need to clip to an additional rectangle
|
||||
// as the cliping to the mask should be sufficient.
|
||||
DrawGradient(cg, aSource);
|
||||
DrawGradient(cg, aSource, CGRectMake(aOffset.x, aOffset.y, size.width, size.height));
|
||||
} else {
|
||||
SetFillFromPattern(cg, mColorSpace, aSource);
|
||||
CGContextFillRect(cg, CGRectMake(aOffset.x, aOffset.y, size.width, size.height));
|
||||
|
@ -585,7 +750,7 @@ DrawTargetCG::FillRect(const Rect &aRect,
|
|||
|
||||
if (isGradient(aPattern)) {
|
||||
CGContextClipToRect(cg, RectToCGRect(aRect));
|
||||
DrawGradient(cg, aPattern);
|
||||
DrawGradient(cg, aPattern, RectToCGRect(aRect));
|
||||
} else {
|
||||
SetFillFromPattern(cg, mColorSpace, aPattern);
|
||||
CGContextFillRect(cg, RectToCGRect(aRect));
|
||||
|
@ -617,9 +782,10 @@ DrawTargetCG::StrokeLine(const Point &p1, const Point &p2, const Pattern &aPatte
|
|||
|
||||
if (isGradient(aPattern)) {
|
||||
CGContextReplacePathWithStrokedPath(cg);
|
||||
CGRect extents = CGContextGetPathBoundingBox(cg);
|
||||
//XXX: should we use EO clip here?
|
||||
CGContextClip(cg);
|
||||
DrawGradient(cg, aPattern);
|
||||
DrawGradient(cg, aPattern, extents);
|
||||
} else {
|
||||
SetStrokeFromPattern(cg, mColorSpace, aPattern);
|
||||
CGContextStrokePath(cg);
|
||||
|
@ -653,9 +819,10 @@ DrawTargetCG::StrokeRect(const Rect &aRect,
|
|||
CGContextBeginPath(cg);
|
||||
CGContextAddRect(cg, RectToCGRect(aRect));
|
||||
CGContextReplacePathWithStrokedPath(cg);
|
||||
CGRect extents = CGContextGetPathBoundingBox(cg);
|
||||
//XXX: should we use EO clip here?
|
||||
CGContextClip(cg);
|
||||
DrawGradient(cg, aPattern);
|
||||
DrawGradient(cg, aPattern, extents);
|
||||
} else {
|
||||
SetStrokeFromPattern(cg, mColorSpace, aPattern);
|
||||
CGContextStrokeRect(cg, RectToCGRect(aRect));
|
||||
|
@ -704,9 +871,10 @@ DrawTargetCG::Stroke(const Path *aPath, const Pattern &aPattern, const StrokeOpt
|
|||
|
||||
if (isGradient(aPattern)) {
|
||||
CGContextReplacePathWithStrokedPath(cg);
|
||||
CGRect extents = CGContextGetPathBoundingBox(cg);
|
||||
//XXX: should we use EO clip here?
|
||||
CGContextClip(cg);
|
||||
DrawGradient(cg, aPattern);
|
||||
DrawGradient(cg, aPattern, extents);
|
||||
} else {
|
||||
// XXX: we could put fill mode into the path fill rule if we wanted
|
||||
|
||||
|
@ -740,19 +908,22 @@ DrawTargetCG::Fill(const Path *aPath, const Pattern &aPattern, const DrawOptions
|
|||
|
||||
if (isGradient(aPattern)) {
|
||||
// setup a clip to draw the gradient through
|
||||
CGRect extents;
|
||||
if (CGPathIsEmpty(cgPath->GetPath())) {
|
||||
// Adding an empty path will cause us not to clip
|
||||
// so clip everything explicitly
|
||||
CGContextClipToRect(mCg, CGRectZero);
|
||||
extents = CGRectZero;
|
||||
} else {
|
||||
CGContextAddPath(cg, cgPath->GetPath());
|
||||
extents = CGContextGetPathBoundingBox(cg);
|
||||
if (cgPath->GetFillRule() == FILL_EVEN_ODD)
|
||||
CGContextEOClip(mCg);
|
||||
else
|
||||
CGContextClip(mCg);
|
||||
}
|
||||
|
||||
DrawGradient(cg, aPattern);
|
||||
DrawGradient(cg, aPattern, extents);
|
||||
} else {
|
||||
CGContextAddPath(cg, cgPath->GetPath());
|
||||
|
||||
|
@ -768,6 +939,29 @@ DrawTargetCG::Fill(const Path *aPath, const Pattern &aPattern, const DrawOptions
|
|||
CGContextRestoreGState(mCg);
|
||||
}
|
||||
|
||||
CGRect ComputeGlyphsExtents(CGRect *bboxes, CGPoint *positions, CFIndex count, float scale)
|
||||
{
|
||||
CGFloat x1, x2, y1, y2;
|
||||
if (count < 1)
|
||||
return CGRectZero;
|
||||
|
||||
x1 = bboxes[0].origin.x + positions[0].x;
|
||||
x2 = bboxes[0].origin.x + positions[0].x + scale*bboxes[0].size.width;
|
||||
y1 = bboxes[0].origin.y + positions[0].y;
|
||||
y2 = bboxes[0].origin.y + positions[0].y + scale*bboxes[0].size.height;
|
||||
|
||||
// accumulate max and minimum coordinates
|
||||
for (int i = 1; i < count; i++) {
|
||||
x1 = min(x1, bboxes[i].origin.x + positions[i].x);
|
||||
y1 = min(y1, bboxes[i].origin.y + positions[i].y);
|
||||
x2 = max(x2, bboxes[i].origin.x + positions[i].x + scale*bboxes[i].size.width);
|
||||
y2 = max(y2, bboxes[i].origin.y + positions[i].y + scale*bboxes[i].size.height);
|
||||
}
|
||||
|
||||
CGRect extents = {{x1, y1}, {x2-x1, y2-y1}};
|
||||
return extents;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
DrawTargetCG::FillGlyphs(ScaledFont *aFont, const GlyphBuffer &aBuffer, const Pattern &aPattern, const DrawOptions &aDrawOptions,
|
||||
|
@ -811,17 +1005,27 @@ DrawTargetCG::FillGlyphs(ScaledFont *aFont, const GlyphBuffer &aBuffer, const Pa
|
|||
//XXX: CGContextShowGlyphsAtPositions is 10.5+ for older versions use CGContextShowGlyphsWithAdvances
|
||||
if (isGradient(aPattern)) {
|
||||
CGContextSetTextDrawingMode(cg, kCGTextClip);
|
||||
CGRect extents;
|
||||
if (ScaledFontMac::CTFontDrawGlyphsPtr != nullptr) {
|
||||
CGRect *bboxes = new CGRect[aBuffer.mNumGlyphs];
|
||||
CTFontGetBoundingRectsForGlyphs(macFont->mCTFont, kCTFontDefaultOrientation,
|
||||
&glyphs.front(), bboxes, aBuffer.mNumGlyphs);
|
||||
extents = ComputeGlyphsExtents(bboxes, &positions.front(), aBuffer.mNumGlyphs, 1.0f);
|
||||
ScaledFontMac::CTFontDrawGlyphsPtr(macFont->mCTFont, &glyphs.front(),
|
||||
&positions.front(),
|
||||
aBuffer.mNumGlyphs, cg);
|
||||
&positions.front(), aBuffer.mNumGlyphs, cg);
|
||||
delete bboxes;
|
||||
} else {
|
||||
CGRect *bboxes = new CGRect[aBuffer.mNumGlyphs];
|
||||
CGFontGetGlyphBBoxes(macFont->mFont, &glyphs.front(), aBuffer.mNumGlyphs, bboxes);
|
||||
extents = ComputeGlyphsExtents(bboxes, &positions.front(), aBuffer.mNumGlyphs, macFont->mSize);
|
||||
|
||||
CGContextSetFont(cg, macFont->mFont);
|
||||
CGContextSetFontSize(cg, macFont->mSize);
|
||||
CGContextShowGlyphsAtPositions(cg, &glyphs.front(), &positions.front(),
|
||||
aBuffer.mNumGlyphs);
|
||||
delete bboxes;
|
||||
}
|
||||
DrawGradient(cg, aPattern);
|
||||
DrawGradient(cg, aPattern, extents);
|
||||
} else {
|
||||
//XXX: with CoreGraphics we can stroke text directly instead of going
|
||||
// through GetPath. It would be nice to add support for using that
|
||||
|
|
Загрузка…
Ссылка в новой задаче