Modules/Sketch2D/tests/Sketch2DUiTests.cpp
Шурупов Илья Викторович fca8fe0902 Render strokes at the shown level and paint full resolution in the background
Dabs now go only into preview tiles of the shown level at every zoom,
including a level 0 preview overlay that the compositor shows in
place of the layer tile. The layer tiles are painted by the engine,
and releasing a stroke commits history without waiting for them.
Stroke opacity coverage is shared per tile across a stroke's flushes,
each engine job only replays the dabs that touch its tile, and undo
drops an open preview instead of painting it. Readers that need final
pixels settle explicitly; the renderer no longer does.

tp::Map::remove rehashed the whole table on every call, which made
applying thousands of finished tiles quadratic; it now rehashes only
when the table is mostly empty or full of deleted slots.
2026-10-20 22:13:30 +03:00

1113 lines
39 KiB
C++

#include "Sketch2DUI.hpp"
#include "Ui.hpp"
#include "UiDriver.hpp"
#include "UnitTest++/UnitTest++.h"
#include <cstdlib>
#include <filesystem>
#include <stdexcept>
using namespace tp;
using namespace tp::sketch2d;
using namespace tp::ui;
using namespace tp::ui::testing;
namespace {
class FakeViewport : public ViewportRenderer {
public:
void render(Document&, const ViewSettings&, Vec2F viewportSize) override {
renders++;
lastViewport = viewportSize;
}
[[nodiscard]] ImageId image() const override { return { 42 }; }
void setDebugOverlay(bool enabled) override { overlay = enabled; }
[[nodiscard]] bool debugOverlay() const override { return overlay; }
[[nodiscard]] std::string statistics() const override { return "fake statistics"; }
int renders = 0;
Vec2F lastViewport = { 0, 0 };
bool overlay = false;
};
class NumberedIcons : public IconProvider {
public:
[[nodiscard]] ImageId icon(const std::string& name) const override { return { (ualni) name.size() + 1 }; }
};
struct Sketch2DScene {
Document document;
Brush brush;
FakeViewport viewport;
NumberedIcons icons;
Sketch2DRoot root{ document, viewport, brush, icons };
Sketch2DScene() { root.canvas.settings().viewStiffness = 1; }
};
MenuItem& itemNamed(Menu& menu, const std::string& text) {
for (const auto& item : menu.items()) {
if (item->entry().text == text) return *item;
}
throw std::runtime_error("menu item not found: " + text);
}
struct FakeRecorderHost : SessionRecorderHost {
WindowInputObserver* tap = nullptr;
double clockMs = 1000;
void setRawInputTap(WindowInputObserver* observer) override { tap = observer; }
[[nodiscard]] SessionEnvironment environment() const override {
return { "test", "fake gpu", "today", { 1280, 800 }, 1 };
}
[[nodiscard]] double nowMs() const override { return clockMs; }
};
size_t menuIndex(Sketch2DRoot& root, const std::string& title) {
for (size_t index = 0; index < root.menuBar.titles().size(); index++) {
if (root.menuBar.titles()[index]->text() == title) return index;
}
throw std::runtime_error("menu not found: " + title);
}
std::string percentLabel(halnf fraction) { return std::to_string((int) std::round(fraction * 100)) + "%"; }
PopupButton& menuTitle(Sketch2DRoot& root, size_t index) { return *root.menuBar.titles()[index]; }
bool hasPaintedPixels(Document& document) {
document.settlePaint();
bool painted = false;
document.forEachVisibleTile([&painted](const TileCoord&, Tile* tile) {
const uint1* pixels = tile->pixels();
for (ualni index = 3; index < (ualni) kTileSize * kTileSize * 4 && !painted; index += 4) {
painted = pixels[index] > 0;
}
});
return painted;
}
}
SUITE(Sketch2DPainting) {
TEST(OverlappingSoftDabsStorePremultipliedColor) {
Document document;
Brush brush;
brush.color = { 1.0f, 0.5f, 0.25f, 1.0f };
brush.setSize(40);
brush.setHardness(0.0f);
brush.setFlow(1.0f);
document.stampBrushDab({ 128.0f, 128.0f }, brush);
document.stampBrushDab({ 136.0f, 128.0f }, brush);
document.settlePaint();
const uint1* pixels = document.activeLayer()->tileIfExists({ 0, 0 })->pixels();
int4 softPixels = 0;
for (ualni index = 0; index < (ualni) kTileSize * kTileSize; index++) {
const uint1* pixel = pixels + index * 4;
const int4 alpha = pixel[3];
if (alpha == 0 || alpha == 255) continue;
softPixels++;
CHECK(pixel[0] <= alpha && pixel[1] <= alpha && pixel[2] <= alpha);
CHECK_CLOSE(alpha * 1.0f, pixel[0], 1.0f);
CHECK_CLOSE(alpha * 0.5f, pixel[1], 1.0f);
CHECK_CLOSE(alpha * 0.25f, pixel[2], 1.0f);
}
CHECK(softPixels > 100);
}
}
SUITE(Sketch2DTileLifetime) {
TEST(ErasingAStrokeRemovesItsTiles) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const RectF canvas = scene.root.canvas.windowRect();
const Vec2F from = canvas.pos + Vec2F(100, 100);
const Vec2F to = canvas.pos + Vec2F(700, 400);
driver.drag(from, to);
const ualni painted = scene.document.tileCount();
CHECK(painted > 1);
scene.brush.type = BrushType::Eraser;
scene.brush.setSize(120);
scene.brush.setFlow(1.0f);
scene.brush.setHardness(1.0f);
driver.drag(from, to);
CHECK_EQUAL(0u, scene.document.tileCount());
CHECK(!hasPaintedPixels(scene.document));
}
TEST(PartialEraseKeepsPaintedTiles) {
Document document;
Brush brush;
brush.setFlow(1.0f);
brush.setSize(kTileSize * 0.25f);
const halnf middle = (halnf) kTileSize * 0.5f;
document.stampBrushDab({ middle, middle }, brush);
document.stampBrushDab({ (halnf) kTileSize * 2.5f, middle }, brush);
document.finishStroke();
CHECK_EQUAL(2u, document.tileCount());
brush.type = BrushType::Eraser;
brush.setSize(kTileSize * 0.5f);
brush.setHardness(1.0f);
document.stampBrushDab({ middle, middle }, brush);
document.finishStroke();
CHECK_EQUAL(1u, document.tileCount());
CHECK(document.activeLayer()->tileIfExists({ 2, 0 }) != nullptr);
}
}
SUITE(Sketch2DLayout) {
TEST(PaintingRendersViewportAtCanvasSize) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
RecordingPainter& painter = driver.paint();
CHECK_EQUAL(1, scene.viewport.renders);
CHECK_CLOSE(scene.root.canvas.size().x, scene.viewport.lastViewport.x, 0.01f);
CHECK(painter.hasText("File"));
CHECK(painter.hasText("Layers"));
CHECK(painter.hasText("10 px"));
CHECK(painter.isBalanced());
}
TEST(ShrinkingWindowKeepsCanvasPositive) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.resize({ 500, 300 });
CHECK(scene.root.canvas.size().x > 0);
CHECK(scene.root.canvas.size().y > 0);
}
}
SUITE(Sketch2DCanvasInteraction) {
TEST(DraggingOnCanvasPaintsAStroke) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const Vec2F start = scene.root.canvas.windowRect().center();
driver.moveTo(start);
driver.press();
CHECK(scene.root.canvas.isPainting());
driver.moveTo(start + Vec2F(120.0f, 40.0f));
driver.release();
CHECK(!scene.root.canvas.isPainting());
CHECK(scene.document.tileCount() > 0);
CHECK(hasPaintedPixels(scene.document));
}
TEST(StrokeContinuesWhenPointerLeavesCanvas) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const Vec2F start = scene.root.canvas.windowRect().center();
driver.moveTo(start);
driver.press();
driver.moveTo({ 10, 10 });
CHECK(scene.root.canvas.isPainting());
CHECK(driver.ui().pointerCapture() == &scene.root.canvas);
driver.release();
}
TEST(MiddleDragPansWithoutPainting) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const Vec2F start = scene.root.canvas.windowRect().center();
driver.moveTo(start);
driver.press(PointerButton::Middle);
CHECK(scene.root.canvas.isPanning());
driver.moveTo(start + Vec2F(100.0f, 0.0f));
driver.release(PointerButton::Middle);
CHECK_CLOSE(-100, scene.document.panWorld().x, 0.01f);
CHECK_EQUAL(0u, scene.document.tileCount());
}
TEST(SpaceDragAlsoPans) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const Vec2F start = scene.root.canvas.windowRect().center();
driver.ui().dispatch(Event::key(Event::Type::KeyDown, InputID::SPACE));
driver.drag(start, start + Vec2F(0.0f, 50.0f));
driver.ui().dispatch(Event::key(Event::Type::KeyUp, InputID::SPACE));
CHECK_CLOSE(-50, scene.document.panWorld().y, 0.01f);
CHECK_EQUAL(0u, scene.document.tileCount());
}
TEST(WheelZoomsAndToolbarFollows) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.moveTo(scene.root.canvas);
driver.wheel(1);
CHECK_CLOSE(1.1f, scene.document.zoom(), 0.001f);
CHECK_EQUAL("110%", scene.root.toolBar.zoom.text());
driver.click(scene.root.toolBar.zoom);
CHECK_CLOSE(1.0f, scene.document.zoom(), 0.001f);
CHECK_EQUAL("100%", scene.root.toolBar.zoom.text());
}
}
SUITE(Sketch2DToolsAndPopups) {
TEST(EraserAndBrushToolsSwitchBrushType) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.click(scene.root.toolStrip.button("eraser"));
CHECK(scene.brush.type == BrushType::Eraser);
CHECK(scene.root.toolStrip.button("eraser").isSelected());
CHECK(!scene.root.toolStrip.button("brush").isSelected());
driver.click(scene.root.toolStrip.button("brush"));
CHECK(scene.brush.type == BrushType::Round);
CHECK(scene.root.toolStrip.button("brush").isSelected());
}
TEST(ColorPopupEraserModeErasesWithTheCurrentBrush) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.brush.apply(*findBrushPreset(BrushType::Pencil));
ColorPopup& popup = scene.root.toolBar.colorPopup;
driver.click(scene.root.toolBar.color);
REQUIRE CHECK(driver.ui().isPopupOpen(popup));
driver.click(popup.eraserMode);
CHECK(scene.brush.erases());
CHECK(scene.brush.type == BrushType::Pencil);
const RectF wheel = popup.wheel.windowRect();
driver.click(wheel.center() + Vec2F(wheel.size.x * 0.3f, 0.0f));
CHECK(!scene.brush.erases());
CHECK(!popup.eraserMode.isChecked());
}
TEST(SmudgeToolSwapsInTheSmudgeBrushAndBack) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.brush.apply(*findBrushPreset(BrushType::Pencil));
driver.click(scene.root.toolStrip.button("smudge"));
CHECK(scene.brush.type == BrushType::Smudge);
CHECK(scene.root.toolStrip.button("smudge").isSelected());
CHECK(!scene.root.toolStrip.button("brush").isSelected());
driver.click(scene.root.toolStrip.button("eraser"));
driver.click(scene.root.toolStrip.button("brush"));
CHECK(scene.brush.type == BrushType::Pencil);
}
TEST(SmudgeStrokeIsLabelledInHistory) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const RectF canvas = scene.root.canvas.windowRect();
driver.drag(canvas.center(), canvas.center() + Vec2F(60.0f, 0.0f));
driver.click(scene.root.toolStrip.button("smudge"));
driver.drag(canvas.center() + Vec2F(30.0f, 0.0f), canvas.center() + Vec2F(30.0f, 60.0f));
CHECK_EQUAL("Smudge", scene.document.history().entries().back().label);
}
TEST(BrushSizePopupChangesRadiusAndToolbarLabel) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
BrushPopup& popup = scene.root.toolBar.brushPopup;
driver.click(scene.root.toolBar.brushSize);
REQUIRE CHECK(driver.ui().isPopupOpen(popup));
CHECK(popup.windowRect().pos.y >= scene.root.toolBar.windowRect().pos.y + scene.root.toolBar.size().y - 1);
const RectF slider = popup.size.slider().windowRect();
driver.drag(slider.center(), { slider.pos.x + slider.size.x, slider.center().y });
CHECK_CLOSE(Brush::kMaximumSize, scene.brush.size(), 0.5f);
CHECK_EQUAL("10000 px", scene.root.toolBar.brushSize.text());
CHECK(driver.ui().isPopupOpen(popup));
driver.click(scene.root.canvas);
CHECK(!driver.ui().isPopupOpen(popup));
}
TEST(ColorPopupPicksBrushColor) {
Sketch2DScene scene;
scene.brush.color = { 0.2f, 0.4f, 0.8f, 1.0f };
UiDriver driver(scene.root, { 1280, 800 });
const RGBA before = scene.brush.color;
scene.brush.type = BrushType::Eraser;
driver.click(scene.root.toolBar.color);
const RectF wheel = scene.root.toolBar.colorPopup.wheel.windowRect();
const halnf ring = (wheel.size.x < wheel.size.y ? wheel.size.x : wheel.size.y) / 2 - 4;
driver.click(wheel.center() + Vec2F(ring, 0.0f));
CHECK(!(scene.brush.color == before));
CHECK(scene.brush.type == BrushType::Round);
}
TEST(LayersPopupManagesDocumentLayers) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
LayersPopup& layers = scene.root.toolBar.layersPopup;
driver.click(scene.root.toolBar.layers);
REQUIRE CHECK(driver.ui().isPopupOpen(layers));
const RectF anchor = scene.root.toolBar.layers.windowRect();
CHECK_CLOSE(anchor.pos.x + anchor.size.x, layers.windowRect().pos.x + layers.size().x, 0.5f);
driver.click(layers.add);
CHECK_EQUAL(2u, scene.document.layerCount());
CHECK_EQUAL(2u, layers.rows().size());
CHECK(scene.document.activeLayer() == &layers.rows()[0]->layer());
driver.click(layers.rows()[1]->name);
CHECK(scene.document.activeLayer() == &layers.rows()[1]->layer());
Layer& bottom = layers.rows()[1]->layer();
driver.click(layers.rows()[1]->visibility);
CHECK(!bottom.visible());
driver.click(layers.rows()[0]->remove);
CHECK_EQUAL(1u, scene.document.layerCount());
CHECK_EQUAL(1u, layers.rows().size());
CHECK(driver.ui().isPopupOpen(layers));
}
}
SUITE(Sketch2DMenus) {
TEST(TileStatisticsToggleShowsOverlay) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.click(menuTitle(scene.root, menuIndex(scene.root, "Debug")));
driver.click(itemNamed(scene.root.debugMenu, "Show Tile Statistics"));
CHECK(scene.viewport.overlay);
CHECK(driver.paint().hasText("fake statistics"));
}
TEST(ClearCanvasRemovesStroke) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const Vec2F start = scene.root.canvas.windowRect().center();
driver.drag(start, start + Vec2F(50.0f, 0.0f));
REQUIRE CHECK(hasPaintedPixels(scene.document));
driver.click(menuTitle(scene.root, menuIndex(scene.root, "Canvas")));
driver.click(itemNamed(scene.root.canvasMenu, "Clear canvas"));
CHECK(!hasPaintedPixels(scene.document));
}
}
SUITE(Sketch2DBrushPressure) {
TEST(BrushPopupTogglesPressurePerParameter) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
BrushPopup& popup = scene.root.toolBar.brushPopup;
driver.click(scene.root.toolBar.brushSize);
REQUIRE CHECK(driver.ui().isPopupOpen(popup));
CHECK(!popup.sizePressure.isChecked());
driver.click(popup.pressureSection.header());
driver.click(popup.sizePressure);
CHECK(scene.brush.usesPressure(PressureTarget::Size));
CHECK(!scene.brush.usesPressure(PressureTarget::Opacity));
driver.click(popup.flowPressure);
CHECK(scene.brush.usesPressure(PressureTarget::Flow));
}
TEST(MouseStrokeIgnoresPressureCurve) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.brush.setUsesPressure(PressureTarget::Size, true);
scene.brush.set(BrushParameter::MinSize, 0);
scene.brush.setSize(40);
const RectF canvas = scene.root.canvas.windowRect();
driver.drag(canvas.center(), canvas.center() + Vec2F(60, 0));
CHECK(hasPaintedPixels(scene.document));
}
}
SUITE(Sketch2DBrushPopupSections) {
TEST(BasicSlidersAreAlwaysVisibleAndGroupsStartClosed) {
Sketch2DScene scene;
scene.brush.apply(*findBrushPreset(BrushType::Calligraphy));
UiDriver driver(scene.root, { 1280, 800 });
BrushPopup& popup = scene.root.toolBar.brushPopup;
popup.refresh();
driver.click(scene.root.toolBar.brushSize);
for (Widget* basic :
std::initializer_list<Widget*>{ &popup.size, &popup.opacity, &popup.flow, &popup.hardness, &popup.stabilizer })
CHECK(basic->parent() == &popup);
for (Collapsible* section : { &popup.shapeSection, &popup.textureSection, &popup.pressureSection }) {
CHECK(!section->isExpanded());
CHECK(!section->isBodyShown());
}
CHECK_EQUAL("25% 45°", popup.shapeSection.header().summary());
CHECK_EQUAL("size", popup.pressureSection.header().summary());
}
TEST(OpenedGroupGrowsThePopupAndFillsItsWidth) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
BrushPopup& popup = scene.root.toolBar.brushPopup;
driver.click(scene.root.toolBar.brushSize);
const halnf closedHeight = popup.rect().size.y;
driver.click(popup.shapeSection.header());
CHECK(popup.shapeSection.isExpanded());
CHECK(popup.rect().size.y > closedHeight + 60);
const RectF body = popup.shapeSection.body().windowRect();
const RectF slider = popup.angle.windowRect();
CHECK_CLOSE(body.pos.x + body.size.x, slider.pos.x + slider.size.x, 0.5f);
}
TEST(SummaryFollowsGroupedSliderChanges) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
BrushPopup& popup = scene.root.toolBar.brushPopup;
driver.click(scene.root.toolBar.brushSize);
driver.click(popup.textureSection.header());
driver.setSlider(popup.texture.slider(), 0.6f);
CHECK_EQUAL(percentLabel(scene.brush.get(BrushParameter::Texture)), popup.textureSection.header().summary());
}
}
SUITE(Sketch2DBrushShape) {
TEST(BrushPopupEditsRoundnessAngleAndSpacing) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 1200 });
BrushPopup& popup = scene.root.toolBar.brushPopup;
driver.click(scene.root.toolBar.brushSize);
REQUIRE CHECK(driver.ui().isPopupOpen(popup));
driver.click(popup.shapeSection.header());
driver.click(popup.textureSection.header());
driver.setSlider(popup.roundness.slider(), 0.5f);
driver.setSlider(popup.angle.slider(), 90);
driver.setSlider(popup.spacing.slider(), 1);
driver.setSlider(popup.texture.slider(), 0.5f);
CHECK_CLOSE(0.5f, scene.brush.get(BrushParameter::Roundness), 0.03f);
CHECK_CLOSE(90, scene.brush.get(BrushParameter::Angle), 3);
CHECK_CLOSE(1, scene.brush.spacing(), 0.03f);
CHECK_CLOSE(0.5f, scene.brush.get(BrushParameter::Texture), 0.03f);
}
TEST(SmudgeGroupAppearsOnlyForTheSmudgeBrush) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 1200 });
BrushPopup& popup = scene.root.toolBar.brushPopup;
driver.click(scene.root.toolBar.brushSize);
CHECK(!popup.smudgeSection.isVisible());
driver.click(scene.root.toolBar.brushSize);
driver.click(scene.root.toolStrip.button("smudge"));
driver.click(scene.root.toolBar.brushSize);
REQUIRE CHECK(popup.smudgeSection.isVisible());
CHECK(!popup.markerBlend.isVisible());
driver.click(popup.smudgeSection.header());
driver.setSlider(popup.blur.slider(), 4);
driver.setSlider(popup.smudgeAmount.slider(), 0.5f);
driver.click(popup.linearMixing);
CHECK_CLOSE(4, scene.brush.get(BrushParameter::Blur), 0.1f);
CHECK_CLOSE(0.5f, scene.brush.get(BrushParameter::Smudge), 0.02f);
CHECK(scene.brush.linearMixing());
CHECK(popup.smudgeSection.header().summary().ends_with("linear"));
}
TEST(ShapeGroupTogglesMarkerBlending) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 1200 });
BrushPopup& popup = scene.root.toolBar.brushPopup;
driver.click(scene.root.toolBar.brushSize);
driver.click(popup.shapeSection.header());
CHECK(!popup.markerBlend.isChecked());
driver.click(popup.markerBlend);
CHECK(scene.brush.markerBlend());
CHECK(popup.shapeSection.header().summary().ends_with("marker"));
scene.brush.apply(*findBrushPreset(BrushType::Round));
popup.refresh();
CHECK(!popup.markerBlend.isChecked());
}
}
SUITE(Sketch2DBrushList) {
TEST(BrushButtonOpensPresetListAndAppliesChoice) {
Sketch2DScene scene;
scene.brush.apply(*findBrushPreset(BrushType::Round));
UiDriver driver(scene.root, { 1280, 800 });
BrushListPopup& list = scene.root.toolBar.brushListPopup;
driver.click(scene.root.toolBar.brushTool);
REQUIRE CHECK(driver.ui().isPopupOpen(list));
CHECK_EQUAL(9u, list.buttons().size());
CHECK(list.button("Round brush").isSelected());
driver.click(list.button("Calligraphy"));
CHECK(scene.brush.type == BrushType::Calligraphy);
CHECK_EQUAL("Calligraphy", scene.root.toolBar.brushTool.text());
CHECK_EQUAL("12 px", scene.root.toolBar.brushSize.text());
CHECK_CLOSE(45, scene.root.toolBar.brushPopup.angle.value(), 0.01f);
CHECK(list.button("Calligraphy").isSelected());
CHECK(!list.button("Round brush").isSelected());
}
TEST(PresetRowsShowAStrokeSampleWithTheNameOnTheLeft) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
BrushListPopup& list = scene.root.toolBar.brushListPopup;
driver.click(scene.root.toolBar.brushTool);
REQUIRE CHECK(driver.ui().isPopupOpen(list));
driver.settle();
for (const auto& button : list.buttons()) {
const RectF row = button->windowRect();
bool sampled = false;
bool leftText = false;
for (const auto& command : driver.paint().commands()) {
const RectF& rect = command.rect;
if (command.text == button->text()) leftText = rect.pos.x < row.pos.x + row.size.x * 0.35f;
else if (
command.text.empty() && rect.pos.y >= row.pos.y && rect.pos.y + rect.size.y <= row.pos.y + row.size.y &&
rect.pos.x >= row.pos.x && rect.pos.x + rect.size.x <= row.pos.x + row.size.y * 1.5f &&
rect.size.x < row.size.x * 0.5f
)
sampled = true;
}
CHECK(leftText);
CHECK(sampled);
}
}
TEST(EraserRemembersThePreviousBrush) {
Sketch2DScene scene;
scene.brush.apply(*findBrushPreset(BrushType::Ink));
UiDriver driver(scene.root, { 1280, 800 });
driver.click(scene.root.toolStrip.button("eraser"));
CHECK(scene.brush.type == BrushType::Eraser);
CHECK_EQUAL(32.0f, scene.brush.size());
CHECK_CLOSE(32, scene.root.toolBar.brushPopup.size.value(), 0.01f);
driver.click(scene.root.toolStrip.button("brush"));
CHECK(scene.brush.type == BrushType::Ink);
CHECK_EQUAL(7.0f, scene.brush.size());
}
}
SUITE(Sketch2DStabilizer) {
halnf strokeDeviation(Document & document, halnf fromX, halnf toX, halnf centerY) {
document.settlePaint();
halnf sum = 0;
int count = 0;
for (int4 x = (int4) fromX; x < (int4) toX; x += 4) {
halnf weighted = 0;
halnf total = 0;
for (int4 y = (int4) centerY - 40; y < (int4) centerY + 40; y++) {
const Tile* tile = document.activeLayer()->tileIfExists({ floordiv(x, kTileSize), floordiv(y, kTileSize) });
if (!tile) continue;
const int4 lx = x - floordiv(x, kTileSize) * kTileSize;
const int4 ly = y - floordiv(y, kTileSize) * kTileSize;
const halnf alpha = tile->pixels()[((ualni) ly * kTileSize + lx) * 4 + 3];
weighted += alpha * (halnf) y;
total += alpha;
}
if (total <= 0) continue;
const halnf offset = weighted / total - centerY;
sum += offset * offset;
count++;
}
return count ? std::sqrt(sum / (halnf) count) : 0;
}
halnf drawJitteryLine(Sketch2DScene & scene, UiDriver & driver, halnf amount) {
scene.brush.setStabilizer(amount);
scene.brush.setSize(6);
scene.brush.setHardness(1);
const RectF canvas = scene.root.canvas.windowRect();
const Vec2F start = canvas.pos + Vec2F(100, 300);
driver.moveTo(start);
driver.press();
for (int step = 1; step < 160; step++)
driver.moveTo(start + Vec2F((halnf) step * 3.0f, (step / 3) % 2 ? 10.0f : -10.0f));
driver.release();
const Vec2F world = scene.document.screenToWorld(scene.root.canvas.toLocal(start));
return strokeDeviation(scene.document, world.x + 80, world.x + 400, world.y);
}
TEST(StabilizedStrokeIsStraighterThanRawInput) {
Sketch2DScene raw;
UiDriver rawDriver(raw.root, { 1280, 800 });
Sketch2DScene smooth;
UiDriver smoothDriver(smooth.root, { 1280, 800 });
const halnf rawDeviation = drawJitteryLine(raw, rawDriver, 0);
const halnf smoothDeviation = drawJitteryLine(smooth, smoothDriver, 0.6f);
CHECK(rawDeviation > 3.0f);
CHECK(smoothDeviation < rawDeviation * 0.4f);
}
TEST(StabilizerSliderAndMenuShareOneValue) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 1200 });
CHECK_CLOSE(0.325f, scene.brush.stabilizer(), 1e-6f);
scene.root.commands.run(Command::StabilizerIncrease);
CHECK_CLOSE(0.425f, scene.brush.stabilizer(), 1e-5f);
CHECK_CLOSE(0.425f, scene.root.toolBar.brushPopup.stabilizer.value(), 1e-5f);
CHECK_EQUAL("Stabilizer 43%", scene.root.lastNotice());
for (int index = 0; index < 10; index++)
scene.root.commands.run(Command::StabilizerDecrease);
CHECK_EQUAL(0.0f, scene.brush.stabilizer());
}
TEST(GlobalStabilizerIgnoresTheBrushValue) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.brush.setStabilizer(0.9f);
ui::MenuItem* toggle = nullptr;
for (const auto& item : scene.root.stabilizerMenu.items()) {
if (item->entry().text == "Use brush stabilizer") toggle = item.get();
}
REQUIRE CHECK(toggle != nullptr);
toggle->activate();
CHECK(!scene.root.canvas.settings().useBrushStabilizer);
CHECK_CLOSE(Brush::kDefaultStabilizer, scene.root.canvas.activeStabilizer(), 1e-6f);
scene.root.commands.run(Command::StabilizerIncrease);
CHECK_CLOSE(0.9f, scene.brush.stabilizer(), 1e-6f);
CHECK_CLOSE(0.425f, scene.root.canvas.activeStabilizer(), 1e-5f);
}
TEST(PresetsKeepTheirOwnStabilizerUntilReset) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.brush.setStabilizer(0.8f);
for (const auto& item : scene.root.stabilizerMenu.items()) {
if (item->entry().text == "Reset all brush stabilization") item->activate();
}
CHECK_CLOSE(Brush::kDefaultStabilizer, scene.brush.stabilizer(), 1e-6f);
}
TEST(PenRestingLetsTheStabilizedLineReachIt) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.brush.setStabilizer(0.8f);
scene.root.canvas.settings().line.holdForLine = false;
const RectF canvas = scene.root.canvas.windowRect();
driver.moveTo(canvas.pos + Vec2F(100, 300));
driver.press();
driver.moveTo(canvas.pos + Vec2F(400, 300));
const halnf behind = scene.root.canvas.stabilizer().smoothed().position.x;
CHECK(scene.root.canvas.isStabilizerRopeVisible());
CHECK(behind < canvas.pos.x + 400 - scene.root.canvas.windowRect().pos.x - 20);
driver.advance(1500);
CHECK(scene.root.canvas.stabilizer().isSettled());
CHECK(!scene.root.canvas.isStabilizerRopeVisible());
driver.release();
}
}
SUITE(Sketch2DBrushPopupLayout) {
TEST(BasicSlidersComeFirstThenGroupsWithoutOverlap) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 900 });
BrushPopup& popup = scene.root.toolBar.brushPopup;
driver.click(scene.root.toolBar.brushSize);
driver.click(popup.shapeSection.header());
const std::vector<Widget*> order = { &popup.size, &popup.opacity, &popup.flow,
&popup.hardness, &popup.stabilizer, &popup.shapeSection,
&popup.textureSection, &popup.pressureSection };
for (size_t index = 1; index < order.size(); index++) {
const RectF above = order[index - 1]->windowRect();
CHECK(order[index]->windowRect().pos.y >= above.pos.y + above.size.y - 0.5f);
}
const RectF panel = popup.windowRect();
const RectF last = popup.pressureSection.windowRect();
CHECK(last.pos.y + last.size.y <= panel.pos.y + panel.size.y + 0.5f);
CHECK(driver.paint().isBalanced());
}
}
SUITE(Sketch2DSessionRecording) {
struct RecordingScene : Sketch2DScene {
FakeRecorderHost host;
SessionRecorder recorder{ host };
std::filesystem::path directory = std::filesystem::temp_directory_path() / "sketch2d-ui-sessions";
RecordingScene() {
std::filesystem::remove_all(directory);
setenv("SKETCH2D_SESSION_DIR", directory.c_str(), 1);
root.setSessionRecorder(&recorder);
root.sessionTimestamp = []() { return std::string("take-1"); };
}
~RecordingScene() {
root.setSessionRecorder(nullptr);
unsetenv("SKETCH2D_SESSION_DIR");
std::filesystem::remove_all(directory);
}
void runDebugItem(UiDriver& driver, const std::string& item) {
driver.click(menuTitle(root, menuIndex(root, "Debug")));
driver.click(itemNamed(root.debugMenu, item));
}
std::string stopAndSave(UiDriver& driver) {
root.commands.run(Command::StopSessionRecording);
driver.settle();
if (!root.sessionSaveDialog.isOpen()) return {};
driver.key(InputID::ENTER);
driver.settle();
return root.lastSessionDirectory();
}
};
TEST(StopAsksWhereToSaveAndSavesTheChosenFolder) {
RecordingScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.brush.apply(*findBrushPreset(BrushType::Ink));
scene.document.setZoom(2);
CHECK(!scene.root.toolBar.recording.isVisible());
scene.runDebugItem(driver, "Record session");
REQUIRE CHECK(scene.root.isRecordingSession());
CHECK(scene.host.tap != nullptr);
CHECK(scene.root.toolBar.recording.isVisible());
const RectF canvas = scene.root.canvas.windowRect();
scene.host.tap->onPointerMoved(canvas.center());
scene.host.tap->onPointerButton(InputID::MOUSE1, true);
scene.host.clockMs += 65000;
scene.host.tap->onPointerButton(InputID::MOUSE1, false);
driver.advance(32);
CHECK(scene.root.toolBar.recording.text().ends_with("REC 1:05"));
scene.runDebugItem(driver, "Stop recording and save");
driver.settle();
CHECK(!scene.root.isRecordingSession());
CHECK(scene.host.tap == nullptr);
CHECK(!scene.root.toolBar.recording.isVisible());
REQUIRE CHECK(scene.root.sessionSaveDialog.isOpen());
CHECK(scene.root.hasUnsavedSession());
CHECK(scene.root.sessionSaveDialog.mode() == ui::FileDialog::Mode::Save);
CHECK_EQUAL(scene.directory.string(), scene.root.sessionSaveDialog.browser.directory());
CHECK_EQUAL("take-1", scene.root.sessionSaveDialog.name.text());
CHECK(scene.root.sessionSaveDialog.message().find("65.0 s") != std::string::npos);
scene.root.sessionSaveDialog.name.setText("fast-ink");
driver.key(InputID::ENTER);
const std::filesystem::path saved = scene.directory / "fast-ink";
CHECK_EQUAL(saved.string(), scene.root.lastSessionDirectory());
CHECK(!scene.root.hasUnsavedSession());
CHECK(scene.root.lastNotice().find("fast-ink") != std::string::npos);
const std::optional<RecordedSession> session = RecordedSession::load(saved.string());
REQUIRE CHECK(session.has_value());
REQUIRE CHECK_EQUAL(3u, session->input.events.size());
CHECK_EQUAL(65000.0, session->input.events.back().timeMs);
CHECK(session->snapshot.brush.type == BrushType::Ink);
CHECK_EQUAL(2.0f, session->snapshot.view.zoom);
CHECK_EQUAL("fake gpu", session->snapshot.environment.device);
CHECK(session->recordedFrames.rfind("frame\twall_ms\tcpu_ms", 0) == 0);
}
TEST(NextSaveStartsInTheFolderUsedLastTime) {
RecordingScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const std::filesystem::path elsewhere = scene.directory / "nested";
std::filesystem::create_directories(elsewhere);
scene.root.commands.run(Command::RecordSession);
scene.root.commands.run(Command::StopSessionRecording);
driver.settle();
scene.root.sessionSaveDialog.name.setText((elsewhere / "first").string());
driver.key(InputID::ENTER);
CHECK_EQUAL((elsewhere / "first").string(), scene.root.lastSessionDirectory());
scene.root.commands.run(Command::RecordSession);
scene.root.commands.run(Command::StopSessionRecording);
driver.settle();
CHECK_EQUAL(elsewhere.string(), scene.root.sessionSaveDialog.browser.directory());
}
TEST(CancellingTheSaveAsksBeforeDiscarding) {
RecordingScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.root.commands.run(Command::RecordSession);
scene.root.commands.run(Command::StopSessionRecording);
driver.settle();
driver.click(*scene.root.sessionSaveDialog.buttons().back());
driver.settle();
REQUIRE CHECK(scene.root.discardSession.isOpen());
CHECK_EQUAL("Discard the recording?", scene.root.discardSession.title());
CHECK(scene.root.hasUnsavedSession());
driver.click(*scene.root.discardSession.buttons().back());
driver.settle();
CHECK(scene.root.sessionSaveDialog.isOpen());
CHECK(scene.root.hasUnsavedSession());
driver.click(*scene.root.sessionSaveDialog.buttons().back());
driver.settle();
driver.click(*scene.root.discardSession.buttons().front());
CHECK(!scene.root.hasUnsavedSession());
CHECK_EQUAL("Recording discarded", scene.root.lastNotice());
CHECK(!std::filesystem::exists(scene.directory / "take-1"));
}
TEST(StopIsDisabledUntilRecordingAndRecordWhileRecording) {
RecordingScene scene;
UiDriver driver(scene.root, { 1280, 800 });
CHECK(!scene.root.commands.isEnabled(Command::StopSessionRecording));
CHECK(scene.root.commands.isEnabled(Command::RecordSession));
scene.root.commands.run(Command::RecordSession);
CHECK(!scene.root.commands.isEnabled(Command::RecordSession));
CHECK(scene.root.commands.isEnabled(Command::StopSessionRecording));
}
TEST(RecordButtonIsAWideHoverableButtonThatStops) {
RecordingScene scene;
UiDriver driver(scene.root, { 1280, 800 });
scene.root.commands.run(Command::RecordSession);
driver.settle();
Button& button = scene.root.toolBar.recording;
CHECK(driver.paint().hasText(button.text()));
CHECK(button.text().ends_with("REC 0:00"));
CHECK(button.windowRect().size.x >= 100);
CHECK_CLOSE(scene.root.toolBar.undo.windowRect().size.y, button.windowRect().size.y, 1.0f);
driver.moveTo(button);
driver.settle();
CHECK(button.isHovered());
CHECK_EQUAL("Recording session. Click to stop and save", button.tooltip());
driver.click(button);
driver.settle();
CHECK(!scene.root.isRecordingSession());
CHECK(scene.root.sessionSaveDialog.isOpen());
}
TEST(RecordingWithoutARecorderIsUnavailable) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
CHECK(!scene.root.commands.isEnabled(Command::RecordSession));
CHECK(!scene.root.commands.run(Command::RecordSession));
}
TEST(RecordingCapturesTheStateBeforeTheFirstStroke) {
RecordingScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const RectF canvas = scene.root.canvas.windowRect();
driver.drag(canvas.center(), canvas.center() + Vec2F(80.0f, 0.0f));
const ualni tilesBefore = scene.document.tileCount();
scene.root.commands.run(Command::RecordSession);
driver.drag(canvas.center() + Vec2F(0.0f, 300.0f), canvas.center() + Vec2F(80.0f, 300.0f));
const std::string savedTo = scene.stopAndSave(driver);
const std::optional<RecordedSession> saved = RecordedSession::load(savedTo);
REQUIRE CHECK(saved.has_value());
Document restored;
Brush brush;
ViewSettings settings;
saved->snapshot.applyTo(restored, brush, settings);
CHECK_EQUAL(tilesBefore, restored.tileCount());
CHECK(scene.document.tileCount() > tilesBefore);
}
}
SUITE(Sketch2DTouchGestures) {
TEST(TwoFingerDragPansTheCanvas) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const Vec2F center = scene.root.canvas.windowRect().center();
const Vec2F before = scene.document.screenToWorld(scene.root.canvas.toLocal(center));
driver.ui().dispatch(Event::gesture({ GesturePhase::Begin, center }));
driver.ui().dispatch(Event::gesture({ GesturePhase::Update, center + Vec2F(40, 30), { 40, 30 } }));
driver.ui().dispatch(Event::gesture({ GesturePhase::End, center + Vec2F(40, 30) }));
const Vec2F after = scene.document.screenToWorld(scene.root.canvas.toLocal(center + Vec2F(40, 30)));
CHECK_CLOSE(before.x, after.x, 0.01f);
CHECK_CLOSE(before.y, after.y, 0.01f);
CHECK_EQUAL(1u, scene.document.history().entries().size());
}
TEST(PinchZoomsAroundTheFingers) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const Vec2F point = scene.root.canvas.windowRect().pos + Vec2F(200, 150);
const Vec2F anchor = scene.document.screenToWorld(scene.root.canvas.toLocal(point));
const halnf zoom = scene.document.zoom();
driver.ui().dispatch(Event::gesture({ GesturePhase::Begin, point }));
driver.ui().dispatch(Event::gesture({ GesturePhase::Update, point, { 0, 0 }, 2.0f }));
driver.ui().dispatch(Event::gesture({ GesturePhase::End, point }));
CHECK_CLOSE(zoom * 2.0f, scene.document.zoom(), 0.001f);
const Vec2F stays = scene.document.screenToWorld(scene.root.canvas.toLocal(point));
CHECK_CLOSE(anchor.x, stays.x, 0.01f);
CHECK_CLOSE(anchor.y, stays.y, 0.01f);
}
TEST(SecondFingerDiscardsTheDabTheFirstFingerStarted) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const Vec2F center = scene.root.canvas.windowRect().center();
driver.moveTo(center);
driver.press();
driver.moveTo(center + Vec2F(4, 0));
REQUIRE CHECK(scene.root.canvas.isPainting());
driver.ui().dispatch(Event::gesture({ GesturePhase::Begin, center + Vec2F(50, 0) }));
driver.release();
driver.ui().dispatch(Event::gesture({ GesturePhase::Update, center + Vec2F(60, 0), { 10, 0 } }));
driver.ui().dispatch(Event::gesture({ GesturePhase::End, center + Vec2F(60, 0) }));
CHECK(!scene.root.canvas.isPainting());
CHECK_EQUAL(0u, scene.document.tileCount());
CHECK_EQUAL(1u, scene.document.history().entries().size());
}
TEST(FingerThatBecomesAPinchLeavesNoDab) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.setAutoSettle(false);
driver.setPointerTool(PointerTool::Finger);
const Vec2F center = scene.root.canvas.windowRect().center();
driver.moveTo(center);
driver.press();
driver.advance(30);
CHECK(scene.root.canvas.isWaitingForFinger());
driver.ui().dispatch(Event::gesture({ GesturePhase::Begin, center + Vec2F(60, 0) }));
driver.release();
driver.ui().dispatch(Event::gesture({ GesturePhase::Update, center + Vec2F(70, 0), { 10, 0 }, 1.3f }));
driver.ui().dispatch(Event::gesture({ GesturePhase::End, center + Vec2F(70, 0) }));
driver.advance(400);
CHECK_EQUAL(0u, scene.document.tileCount());
CHECK_EQUAL(1u, scene.document.history().entries().size());
}
TEST(LastFingerLiftedAfterAPinchDoesNotPaint) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.setAutoSettle(false);
driver.setPointerTool(PointerTool::Finger);
const Vec2F center = scene.root.canvas.windowRect().center();
driver.ui().dispatch(Event::gesture({ GesturePhase::Begin, center }));
driver.ui().dispatch(Event::gesture({ GesturePhase::Update, center, { 0, 0 }, 0.8f }));
driver.ui().dispatch(Event::gesture({ GesturePhase::End, center }));
driver.moveTo(center + Vec2F(30, 10));
driver.press();
driver.advance(120);
driver.moveTo(center + Vec2F(80, 10));
driver.release();
driver.advance(400);
CHECK_EQUAL(0u, scene.document.tileCount());
CHECK_EQUAL(1u, scene.document.history().entries().size());
}
TEST(SingleFingerHoldStartsPaintingAfterAShortWait) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.setAutoSettle(false);
driver.setPointerTool(PointerTool::Finger);
const Vec2F center = scene.root.canvas.windowRect().center();
driver.moveTo(center);
driver.press();
CHECK(!scene.root.canvas.isPainting());
CHECK_EQUAL(0u, scene.document.tileCount());
driver.advance(CanvasWidget::kFingerCommitMs + 40);
CHECK(scene.root.canvas.isPainting());
driver.moveTo(center + Vec2F(5, 0));
driver.release();
CHECK(scene.document.tileCount() > 0);
CHECK_EQUAL(2u, scene.document.history().entries().size());
}
TEST(QuickFingerStrokeKeepsEveryPointItMoved) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.setAutoSettle(false);
driver.setPointerTool(PointerTool::Finger);
const Vec2F start = scene.root.canvas.windowRect().center();
driver.moveTo(start);
driver.press();
driver.moveTo(start + Vec2F(6, 0));
driver.moveTo(start + Vec2F(12, 0));
CHECK(scene.root.canvas.isWaitingForFinger());
driver.moveTo(start + Vec2F(200, 0));
CHECK(scene.root.canvas.isPainting());
driver.release();
driver.advance(500);
const Vec2F a = scene.document.screenToWorld(scene.root.canvas.toLocal(start + Vec2F(3, 0)));
const Vec2F b = scene.document.screenToWorld(scene.root.canvas.toLocal(start + Vec2F(170, 0)));
CHECK(scene.document.sampleColor(a, false).a > 0.5f);
CHECK(scene.document.sampleColor(b, false).a > 0.5f);
CHECK_EQUAL(2u, scene.document.history().entries().size());
}
TEST(FingerTapStillLeavesADot) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.setAutoSettle(false);
driver.setPointerTool(PointerTool::Finger);
const Vec2F center = scene.root.canvas.windowRect().center();
driver.moveTo(center);
driver.press();
driver.release();
CHECK(scene.document.tileCount() > 0);
CHECK_EQUAL(2u, scene.document.history().entries().size());
}
TEST(PenIsNeverDelayed) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
driver.setAutoSettle(false);
driver.setPointerTool(PointerTool::Pen);
const Vec2F center = scene.root.canvas.windowRect().center();
driver.moveTo(center);
driver.press();
CHECK(scene.root.canvas.isPainting());
CHECK(scene.document.tileCount() > 0);
driver.release();
}
TEST(OneFingerStillPaints) {
Sketch2DScene scene;
UiDriver driver(scene.root, { 1280, 800 });
const Vec2F center = scene.root.canvas.windowRect().center();
driver.drag(center, center + Vec2F(80, 0));
CHECK(scene.document.tileCount() > 0);
CHECK_EQUAL(2u, scene.document.history().entries().size());
}
}