awtk icon indicating copy to clipboard operation
awtk copied to clipboard

awtk很多控件比如progress_circle效率低下的问题

Open smiletigerpp opened this issue 3 years ago • 5 comments

在awtk中优化的很不好,虽然可以用,但是效率非常低下,比如一个progress_circle控件,粗暴的强制刷新该控件的长宽占用的矩形框,而非实际使用到的矩形框,这个控件一般用来指示进度,那么实际利用率仅仅为一小段弧形,并且每次更新的时候更加是弧形里面的一小段,但是awtk中每一帧更新的为该控件的长款的矩形,非常影响效率,在没有用该控件之前我可以刷新率达到125帧/秒,用了一个400*400的progress_circle控件之后,刷新率降到了恐怖的30帧,,可以学习一下touchgfx的circle控件,源码touhgfx里面都有,他就是会去获取一个实际用到的圆弧上的一小段,同样用400x400的控件,如果我实际上使用到的圆弧宽度为20的话,awtk直接每一帧率刷新400x400的区域,在touchgfx里面每一帧刷新的区域只有20x20不到,性能差了好几百倍.所以请求awtk能否考虑一下性能上的优化,而非一味地增加功能,毕竟awtk面向的是跨平台,性能还是首要的,

我这里可以附上touchgfx的算法,可以在touchgfx的源码里面找到

/**


  • This file is part of the TouchGFX 4.16.1 distribution.
  • © Copyright (c) 2021 STMicroelectronics.

  • All rights reserved.
  • This software component is licensed by ST under Ultimate Liberty license
  • SLA0044, the "License"; You may not use this file except in compliance with
  • the License. You may obtain a copy of the License at:
  •                         www.st.com/SLA0044
    

*/

#include <touchgfx/widgets/canvas/Circle.hpp>

namespace touchgfx { Circle::Circle() : CanvasWidget(), circleCenterX(0), circleCenterY(0), circleRadius(0), circleArcAngleStart(CWRUtil::toQ5(0)), circleArcAngleEnd(CWRUtil::toQ5(360)), circleLineWidth(0), circleArcIncrement(5), circleCapArcIncrement(180) { Drawable::setWidthHeight(0, 0); }

void Circle::setPrecision(int precision) { if (precision < 1) { precision = 1; } if (precision > 120) { precision = 120; } circleArcIncrement = precision; }

int Circle::getPrecision() const { return circleArcIncrement; }

void Circle::setCapPrecision(int precision) { if (precision < 1) { precision = 1; } if (precision > 180) { precision = 180; } circleCapArcIncrement = precision; }

int Circle::getCapPrecision() const { return circleCapArcIncrement; }

bool Circle::drawCanvasWidget(const Rect& invalidatedArea) const { CWRUtil::Q5 arcStart = circleArcAngleStart; CWRUtil::Q5 arcEnd = circleArcAngleEnd;

CWRUtil::Q5 _360 = CWRUtil::toQ5<int>(360);

// Put start before end by swapping
if (arcStart > arcEnd)
{
    CWRUtil::Q5 tmp = arcStart;
    arcStart = arcEnd;
    arcEnd = tmp;
}

if ((arcEnd - arcStart) >= _360)
{
    // The entire circle has to be drawn
    arcStart = CWRUtil::toQ5<int>(0);
    arcEnd = _360;
}

if (circleLineWidth != 0)
{
    // Check if invalidated area is completely inside the circle
    int32_t x1 = int(CWRUtil::toQ5(invalidatedArea.x)); // Take the corners of the invalidated area
    int32_t x2 = int(CWRUtil::toQ5(invalidatedArea.right()));
    int32_t y1 = int(CWRUtil::toQ5(invalidatedArea.y));
    int32_t y2 = int(CWRUtil::toQ5(invalidatedArea.bottom()));
    int32_t dx1 = abs(int(circleCenterX) - x1); // Find distances between each corner and circle center
    int32_t dx2 = abs(int(circleCenterX) - x2);
    int32_t dy1 = abs(int(circleCenterY) - y1);
    int32_t dy2 = abs(int(circleCenterY) - y2);
    int32_t dx = CWRUtil::Q5(MAX(dx1, dx2)).to<int>() + 1; // Largest hor/vert distance (round up)
    int32_t dy = CWRUtil::Q5(MAX(dy1, dy2)).to<int>() + 1;
    int32_t dsqr = (dx * dx) + (dy * dy); // Pythagoras

    // From https://www.mathopenref.com/polygonincircle.html
    int32_t rmin = ((circleRadius - (circleLineWidth / 2)) * CWRUtil::cosine((circleArcIncrement + 1) / 2)).to<int>();

    // Check if invalidatedArea is completely inside circle
    if (dsqr < rmin * rmin)
    {
        return true;
    }
}

Canvas canvas(this, invalidatedArea);

CWRUtil::Q5 radius = circleRadius;
CWRUtil::Q5 lineWidth = circleLineWidth;
if (circleLineWidth > circleRadius * 2)
{
    lineWidth = (circleRadius + circleLineWidth / 2);
    radius = lineWidth / 2;
}

CWRUtil::Q5 arc = arcStart;
CWRUtil::Q5 circleArcIncrementQ5 = CWRUtil::toQ5<int>(circleArcIncrement);
moveToAR2(canvas, arc, (radius * 2) + lineWidth);
CWRUtil::Q5 nextArc = CWRUtil::Q5(ROUNDUP((int)(arc + CWRUtil::toQ5<int>(1)), (int)circleArcIncrementQ5));
while (nextArc <= arcEnd)
{
    arc = nextArc;
    lineToAR2(canvas, arc, (radius * 2) + lineWidth);
    nextArc = nextArc + circleArcIncrementQ5;
}
if (arc < arcEnd)
{
    // "arc" is not updated. It is the last arc in steps of "circleArcIncrement"
    lineToAR2(canvas, arcEnd, (radius * 2) + lineWidth);
}

if (lineWidth == CWRUtil::toQ5<int>(0))
{
    // Draw a filled circle / pie / pacman
    if (arcEnd - arcStart < _360)
    {
        // Not a complete circle, line to center
        canvas.lineTo(circleCenterX, circleCenterY);
    }
}
else
{
    CWRUtil::Q5 circleCapArcIncrementQ5 = CWRUtil::toQ5<int>(circleCapArcIncrement);
    CWRUtil::Q5 _180 = CWRUtil::toQ5<int>(180);
    if (arcEnd - arcStart < _360)
    {
        // Draw the circle cap
        CWRUtil::Q5 capX = circleCenterX + (radius * CWRUtil::sine(arcEnd));
        CWRUtil::Q5 capY = circleCenterY - (radius * CWRUtil::cosine(arcEnd));
        for (CWRUtil::Q5 capAngle = arcEnd + circleCapArcIncrementQ5; capAngle < arcEnd + _180; capAngle = capAngle + circleCapArcIncrementQ5)
        {
            lineToXYAR2(canvas, capX, capY, capAngle, lineWidth);
        }
    }

    // Not a filled circle, draw the path on the inside of the circle
    if (arc < arcEnd)
    {
        lineToAR2(canvas, arcEnd, (radius * 2) - lineWidth);
    }

    nextArc = arc;
    while (nextArc >= arcStart)
    {
        arc = nextArc;
        lineToAR2(canvas, arc, (radius * 2) - lineWidth);
        nextArc = nextArc - circleArcIncrementQ5;
    }

    if (arc > arcStart)
    {
        lineToAR2(canvas, arcStart, (radius * 2) - lineWidth);
    }

    if (arcEnd - arcStart < _360)
    {
        // Draw the circle cap
        CWRUtil::Q5 capX = circleCenterX + (radius * CWRUtil::sine(arcStart));
        CWRUtil::Q5 capY = circleCenterY - (radius * CWRUtil::cosine(arcStart));
        for (CWRUtil::Q5 capAngle = arcStart - _180 + circleCapArcIncrementQ5; capAngle < arcStart; capAngle = capAngle + circleCapArcIncrementQ5)
        {
            lineToXYAR2(canvas, capX, capY, capAngle, lineWidth);
        }
    }
}

return canvas.render();

}

Rect Circle::getMinimalRect() const { return getMinimalRect(circleArcAngleStart, circleArcAngleEnd); }

Rect Circle::getMinimalRect(int16_t arcStart, int16_t arcEnd) const { return getMinimalRect(CWRUtil::toQ5(arcStart), CWRUtil::toQ5(arcEnd)); }

Rect Circle::getMinimalRect(CWRUtil::Q5 arcStart, CWRUtil::Q5 arcEnd) const { CWRUtil::Q5 xMin = CWRUtil::toQ5(getWidth()); CWRUtil::Q5 xMax = CWRUtil::toQ5(0); CWRUtil::Q5 yMin = CWRUtil::toQ5(getHeight()); CWRUtil::Q5 yMax = CWRUtil::toQ5(0); calculateMinimalRect(arcStart, arcEnd, xMin, xMax, yMin, yMax); return Rect(xMin.to() - 1, yMin.to() - 1, xMax.to() - xMin.to() + 2, yMax.to() - yMin.to() + 2); }

void Circle::updateArc(CWRUtil::Q5 setStartAngleQ5, CWRUtil::Q5 setEndAngleQ5) { CWRUtil::Q5 startAngleQ5 = setStartAngleQ5; CWRUtil::Q5 endAngleQ5 = setEndAngleQ5; if (circleArcAngleStart == startAngleQ5 && circleArcAngleEnd == endAngleQ5) { return; }

// Make sure old start < end
if (circleArcAngleStart > circleArcAngleEnd)
{
    CWRUtil::Q5 tmp = circleArcAngleStart;
    circleArcAngleStart = circleArcAngleEnd;
    circleArcAngleEnd = tmp;
}
// Make sure new start < end
if (startAngleQ5 > endAngleQ5)
{
    CWRUtil::Q5 tmp = startAngleQ5;
    startAngleQ5 = endAngleQ5;
    endAngleQ5 = tmp;
}

// Nice constant
const CWRUtil::Q5 _360 = CWRUtil::toQ5<int>(360);

// Get old circle range start in [0..360[
if (circleArcAngleStart >= _360)
{
    int x = (circleArcAngleStart / _360).to<int>();
    circleArcAngleStart = circleArcAngleStart - _360 * x;
    circleArcAngleEnd = circleArcAngleEnd - _360 * x;
}
else if (circleArcAngleStart < 0)
{
    int x = 1 + ((-circleArcAngleStart) / _360).to<int>();
    circleArcAngleStart = circleArcAngleStart + _360 * x;
    circleArcAngleEnd = circleArcAngleEnd + _360 * x;
}
// Detect full circle
if ((circleArcAngleEnd - circleArcAngleStart) > _360)
{
    circleArcAngleEnd = circleArcAngleStart + _360;
}

// Get new circle range start in [0..360[
if (startAngleQ5 >= _360)
{
    int x = (startAngleQ5 / _360).to<int>();
    startAngleQ5 = startAngleQ5 - _360 * x;
    endAngleQ5 = endAngleQ5 - _360 * x;
}
else if (startAngleQ5 < 0)
{
    int x = 1 + (-startAngleQ5 / _360).to<int>();
    startAngleQ5 = startAngleQ5 + _360 * x;
    endAngleQ5 = endAngleQ5 + _360 * x;
}
// Detect full circle
if ((endAngleQ5 - startAngleQ5) >= _360)
{
    // Align full new circle with old start.
    // So old[90..270] -> new[0..360] becomes new[90..450] for smaller invalidated area
    startAngleQ5 = circleArcAngleStart;
    endAngleQ5 = startAngleQ5 + _360;
}
else if ((circleArcAngleEnd - circleArcAngleStart) >= _360)
{
    // New circle is not full, but old is. Align old circle with new.
    // So old[0..360] -> new[90..270] becomes old[90..450] for smaller invalidated area
    circleArcAngleStart = startAngleQ5;
    circleArcAngleEnd = circleArcAngleStart + _360;
}

// New start is after old end. Could be overlap
// if old[10..30]->new[350..380] becomes new[-10..20]
if (startAngleQ5 > circleArcAngleEnd && endAngleQ5 - _360 >= circleArcAngleStart)
{
    startAngleQ5 = startAngleQ5 - _360;
    endAngleQ5 = endAngleQ5 - _360;
}
// Same as above but for old instead of new
if (circleArcAngleStart > endAngleQ5 && circleArcAngleEnd - _360 >= startAngleQ5)
{
    circleArcAngleStart = circleArcAngleStart - _360;
    circleArcAngleEnd = circleArcAngleEnd - _360;
}

Rect r;
if (startAngleQ5 > circleArcAngleEnd || endAngleQ5 < circleArcAngleStart)
{
    // Arcs do not overlap. Invalidate both arcs.
    r = getMinimalRect(circleArcAngleStart, circleArcAngleEnd);
    invalidateRect(r);

    r = getMinimalRect(startAngleQ5, endAngleQ5);
    invalidateRect(r);
}
else
{
    // Arcs overlap. Invalidate both ends.
    if (circleArcAngleStart != startAngleQ5)
    {
        r = getMinimalRectForUpdatedStartAngle(startAngleQ5);
        invalidateRect(r);
    }
    if (circleArcAngleEnd != endAngleQ5)
    {
        r = getMinimalRectForUpdatedEndAngle(endAngleQ5);
        invalidateRect(r);
    }
}

circleArcAngleStart = setStartAngleQ5;
circleArcAngleEnd = setEndAngleQ5;

}

void Circle::moveToAR2(Canvas& canvas, const CWRUtil::Q5& angle, const CWRUtil::Q5& r2) const { canvas.moveTo(circleCenterX + ((r2 * CWRUtil::sine(angle)) / 2), circleCenterY - ((r2 * CWRUtil::cosine(angle)) / 2)); }

void Circle::lineToAR2(Canvas& canvas, const CWRUtil::Q5& angle, const CWRUtil::Q5& r2) const { lineToXYAR2(canvas, circleCenterX, circleCenterY, angle, r2); }

void Circle::lineToXYAR2(Canvas& canvas, const CWRUtil::Q5& x, const CWRUtil::Q5& y, const CWRUtil::Q5& angle, const CWRUtil::Q5& r2) const { canvas.lineTo(x + ((r2 * CWRUtil::sine(angle)) / 2), y - ((r2 * CWRUtil::cosine(angle)) / 2)); }

void Circle::updateMinMaxAR(const CWRUtil::Q5& a, const CWRUtil::Q5& r2, CWRUtil::Q5& xMin, CWRUtil::Q5& xMax, CWRUtil::Q5& yMin, CWRUtil::Q5& yMax) const { CWRUtil::Q5 xNew = circleCenterX + ((r2 * CWRUtil::sine(a)) / 2); CWRUtil::Q5 yNew = circleCenterY - ((r2 * CWRUtil::cosine(a)) / 2); updateMinMaxXY(xNew, yNew, xMin, xMax, yMin, yMax); }

void Circle::updateMinMaxXY(const CWRUtil::Q5& xNew, const CWRUtil::Q5& yNew, CWRUtil::Q5& xMin, CWRUtil::Q5& xMax, CWRUtil::Q5& yMin, CWRUtil::Q5& yMax) const { if (xNew < xMin) { xMin = xNew; } if (xNew > xMax) { xMax = xNew; } if (yNew < yMin) { yMin = yNew; } if (yNew > yMax) { yMax = yNew; } }

void Circle::calculateMinimalRect(CWRUtil::Q5 arcStart, CWRUtil::Q5 arcEnd, CWRUtil::Q5& xMin, CWRUtil::Q5& xMax, CWRUtil::Q5& yMin, CWRUtil::Q5& yMax) const { // Put start before end by swapping if (arcStart > arcEnd) { CWRUtil::Q5 tmp = arcStart; arcStart = arcEnd; arcEnd = tmp; }

CWRUtil::Q5 _90 = CWRUtil::toQ5<int>(90);
CWRUtil::Q5 _360 = CWRUtil::toQ5<int>(360);

if ((arcEnd - arcStart) >= _360)
{
    // The entire circle has to be drawn
    arcStart = CWRUtil::toQ5<int>(0);
    arcEnd = _360;
}

// Check start angle
updateMinMaxAR(arcStart, (circleRadius * 2) + circleLineWidth, xMin, xMax, yMin, yMax);
// Here we have a up to 4 approximation steps on angles divisible by 90
CWRUtil::Q5 i;
for (i = CWRUtil::Q5(ROUNDUP((int)(arcStart + CWRUtil::toQ5<int>(1)), (int)_90)); i <= arcEnd; i = i + _90)
{
    updateMinMaxAR(i, (circleRadius * 2) + circleLineWidth, xMin, xMax, yMin, yMax);
}
// Check end angle
if ((i - _90) < arcEnd)
{
    updateMinMaxAR(arcEnd, (circleRadius * 2) + circleLineWidth, xMin, xMax, yMin, yMax);
}

if (circleLineWidth == CWRUtil::toQ5<int>(0))
{
    // A filled circle / pie / pacman
    if ((arcEnd - arcStart) < _360)
    {
        // Not a complete circle, check center
        updateMinMaxAR(CWRUtil::toQ5<int>(0), CWRUtil::toQ5<int>(0), xMin, xMax, yMin, yMax);
    }
}
else
{
    // Not a filled circle, check the inside of the circle. Only start and/or end can cause new min/max values
    updateMinMaxAR(arcStart, (circleRadius * 2) - circleLineWidth, xMin, xMax, yMin, yMax);
    updateMinMaxAR(arcEnd, (circleRadius * 2) - circleLineWidth, xMin, xMax, yMin, yMax);
}

// Check if circle cap extends the min/max further
if ((circleCapArcIncrement < 180) && (arcEnd - arcStart < _360))
{
    // Round caps
    CWRUtil::Q5 capX = circleCenterX + (circleRadius * CWRUtil::sine(arcStart));
    CWRUtil::Q5 capY = circleCenterY - (circleRadius * CWRUtil::cosine(arcStart));
    updateMinMaxXY(capX - (circleLineWidth / 2), capY - (circleLineWidth / 2), xMin, xMax, yMin, yMax);
    updateMinMaxXY(capX + (circleLineWidth / 2), capY + (circleLineWidth / 2), xMin, xMax, yMin, yMax);
    capX = circleCenterX + (circleRadius * CWRUtil::sine(arcEnd));
    capY = circleCenterY - (circleRadius * CWRUtil::cosine(arcEnd));
    updateMinMaxXY(capX - (circleLineWidth / 2), capY - (circleLineWidth / 2), xMin, xMax, yMin, yMax);
    updateMinMaxXY(capX + (circleLineWidth / 2), capY + (circleLineWidth / 2), xMin, xMax, yMin, yMax);
}

}

Rect Circle::getMinimalRectForUpdatedStartAngle(const CWRUtil::Q5& startAngleQ5) const { CWRUtil::Q5 minAngle = CWRUtil::Q5(0); // Unused default value CWRUtil::Q5 maxAngle = CWRUtil::Q5(0); // Unused default value int circleArcIncrementQ5int = (int)CWRUtil::toQ5(circleArcIncrement); if (circleArcAngleStart < circleArcAngleEnd) { // start is smaller than end if (startAngleQ5 < circleArcAngleStart) { // start moved even lower minAngle = startAngleQ5; maxAngle = CWRUtil::Q5(ROUNDUP((int)circleArcAngleStart, circleArcIncrementQ5int)); maxAngle = MIN(maxAngle, circleArcAngleEnd); // No need to go higher than end } else if (startAngleQ5 < circleArcAngleEnd) { // start moved higher, but not higher than end minAngle = circleArcAngleStart; maxAngle = CWRUtil::Q5(ROUNDUP((int)startAngleQ5, circleArcIncrementQ5int)); maxAngle = MIN(maxAngle, circleArcAngleEnd); // No need to go higher than end } else { // start moved past end minAngle = circleArcAngleStart; maxAngle = startAngleQ5; } } else { // start is higher than end if (startAngleQ5 > circleArcAngleStart) { // start moved even higher minAngle = CWRUtil::Q5(ROUNDDOWN((int)circleArcAngleStart, circleArcIncrementQ5int)); minAngle = MAX(minAngle, circleArcAngleEnd); // No need to go lower then end maxAngle = startAngleQ5; } else if (startAngleQ5 > circleArcAngleEnd) { // start moved lower, but not lower than end minAngle = CWRUtil::Q5(ROUNDDOWN((int)startAngleQ5, circleArcIncrementQ5int)); minAngle = MAX(minAngle, circleArcAngleEnd); // No need to go lower than end maxAngle = circleArcAngleStart; } else { // start moved lower past end minAngle = startAngleQ5; maxAngle = circleArcAngleStart; } } return getMinimalRect(minAngle, maxAngle); }

Rect Circle::getMinimalRectForUpdatedEndAngle(const CWRUtil::Q5& endAngleQ5) const { CWRUtil::Q5 minAngle = CWRUtil::Q5(0); // Unused default value CWRUtil::Q5 maxAngle = CWRUtil::Q5(0); // Unused default value int circleArcIncrementQ5int = (int)CWRUtil::toQ5(circleArcIncrement); if (circleArcAngleStart < circleArcAngleEnd) { // start is smaller than end if (endAngleQ5 > circleArcAngleEnd) { // end moved even higher minAngle = CWRUtil::Q5(ROUNDDOWN((int)circleArcAngleEnd, circleArcIncrementQ5int)); minAngle = MAX(minAngle, circleArcAngleStart); maxAngle = endAngleQ5; } else if (endAngleQ5 > circleArcAngleStart) { // end moved lower, but not past start minAngle = CWRUtil::Q5(ROUNDDOWN((int)endAngleQ5, circleArcIncrementQ5int)); minAngle = MAX(minAngle, circleArcAngleStart); // No need to go lower than start maxAngle = circleArcAngleEnd; } else { // end move past start minAngle = endAngleQ5; maxAngle = circleArcAngleEnd; } } else { // start is higher than end if (endAngleQ5 < circleArcAngleEnd) { // end moved even lower minAngle = endAngleQ5; maxAngle = CWRUtil::Q5(ROUNDUP((int)circleArcAngleEnd, circleArcIncrementQ5int)); maxAngle = MIN(maxAngle, circleArcAngleStart); // No need to go higher than start } else if (endAngleQ5 < circleArcAngleStart) { // end moved higher, but not higher than start minAngle = circleArcAngleEnd; maxAngle = CWRUtil::Q5(ROUNDUP((int)endAngleQ5, circleArcIncrementQ5int)); maxAngle = MIN(maxAngle, circleArcAngleStart); } else { // end moved past start minAngle = circleArcAngleEnd; maxAngle = endAngleQ5; } } return getMinimalRect(minAngle, maxAngle); } } // namespace touchgfx

smiletigerpp avatar Jan 30 '22 17:01 smiletigerpp

你帮我用这个分支测试吧,谢谢:https://github.com/zlgopen/awtk/tree/opt_progress_circle

xianjimli avatar Feb 05 '22 03:02 xianjimli

好的 愿意帮忙测试 希望awtk越来越好 这个是不是还跟脏矩形那边lcd.inc那个也需要优化。这个刷新区域是脏矩形那边获取判断的吧

smiletigerpp avatar Feb 05 '22 04:02 smiletigerpp

你帮我用这个分支测试吧,谢谢:https://github.com/zlgopen/awtk/tree/opt_progress_circle

我测试了一下比之前好很多很多了,已经可以用在仪表上面60帧率了,但是还是觉得有优化的空间,比如400x400的控件,然后linewidth设置为100,如果我背景色为透明色,前景为图片,实际前景图片只有线宽10的有效区域,那么实际上percent的控件还是按照100线宽来计算脏矩形的,是否可以智能的去判断透明区域,把透明区域减掉,这样还可以继续优化比较大的空间,就是前景背景在处理透明的时候要把透明区域剪裁掉,当然我只是用printf来debug测试了一下,还没有用到jlink来具体测试,我会继续关注的,非常感谢大佬用心过年还在加班解决优化

smiletigerpp avatar Feb 05 '22 07:02 smiletigerpp

好的,谢谢。

“linewidth设置为100,如果我背景色为透明色,前景为图片,实际前景图片只有线宽10的有效区域”有实际需求,还是只是方便一点?“智能的去判断透明区域”也是需要花时间的,我也不想把代码弄得太复杂。

xianjimli avatar Feb 06 '22 01:02 xianjimli

好的,谢谢。

“linewidth设置为100,如果我背景色为透明色,前景为图片,实际前景图片只有线宽10的有效区域”有实际需求,还是只是方便一点?“智能的去判断透明区域”也是需要花时间的,我也不想把代码弄得太复杂。

实际上这个控件很重要的 经常用在仪表盘上面的 最最关键的一个控件 用来配合指针旋转背景用的 你们awtk那个配合rt1052还是imux6上面那个仪表demo没有用的这个功能 但是基本上所有的仪表控件都是需要这个功能的 这个控件拿来做百分比指示是大材小用了 Uploading MVIMG_20220203_214544.jpg…

smiletigerpp avatar Feb 06 '22 15:02 smiletigerpp