From 18c896545800870dea0d8e48a5beed53f2ee10e7 Mon Sep 17 00:00:00 2001 From: agent Date: Fri, 30 Oct 2026 00:56:55 +0300 Subject: [PATCH 1/3] GraphicApplication: split the canvas into a facade, a contract and backends Canvas keeps the drawing API and its state (origin, clip stack, composite operation) and draws through a CanvasBackend. The backends mirror how the platform windows are organised: the contract (canvas/backends/contract) is the interface, implementations live beside it, and the facade in src/canvas stays portable with no Vulkan. - NanoVgCanvasBackend (canvas/backends/nanovg) draws on a window surface. The canvas's window-surface constructor and beginFrame(frame, windowFrame)/ endFrame() are defined there, next to the backend, so the portable facade never names a GPU frame; the application uses Canvas directly. - RecordingCanvasBackend (canvas/backends/recording) records commands with their surface rect and clip, and measures text with a fixed advance, so layout and painting are testable headless. - Canvas::Clip is a public clip scope; clips intersect and restore with the scope. SavedState now also restores the composite operation. - gradientRect takes an Axis; polyline/polygon take vectors; text takes a string. frame() and debugCross() had no users and are gone. - TextMetrics is an interface Canvas implements, so layout can measure text without a frame. setClip takes an optional clip instead of a null pointer. --- GraphicApplication/CMakeLists.txt | 24 +- .../private/GraphicApplication.cpp | 13 +- .../contract/public/CanvasBackend.hpp | 71 +++ .../backends/contract/public/CanvasTypes.hpp | 66 +++ .../nanovg}/private/CanvasFontLoader.cpp | 0 .../nanovg}/private/CanvasFontLoader.hpp | 0 .../nanovg}/private/CanvasFonts.hpp | 0 .../backends/nanovg/private/CanvasSurface.cpp | 26 + .../nanovg/private/NanoVgCanvasBackend.cpp | 407 ++++++++++++++ .../nanovg/private/NanoVgCanvasBackend.hpp | 81 +++ .../private/RecordingCanvasBackend.cpp | 211 ++++++++ .../public/RecordingCanvasBackend.hpp | 127 +++++ .../src/canvas/private/Canvas.cpp | 507 +++++------------- .../src/canvas/private/CanvasContext.hpp | 24 - .../src/canvas/private/CanvasImages.cpp | 70 --- .../src/canvas/public/Canvas.hpp | 134 ++--- GraphicApplication/tests/CanvasTests.cpp | 201 +++++++ 17 files changed, 1405 insertions(+), 557 deletions(-) create mode 100644 GraphicApplication/src/canvas/backends/contract/public/CanvasBackend.hpp create mode 100644 GraphicApplication/src/canvas/backends/contract/public/CanvasTypes.hpp rename GraphicApplication/src/canvas/{ => backends/nanovg}/private/CanvasFontLoader.cpp (100%) rename GraphicApplication/src/canvas/{ => backends/nanovg}/private/CanvasFontLoader.hpp (100%) rename GraphicApplication/src/canvas/{ => backends/nanovg}/private/CanvasFonts.hpp (100%) create mode 100644 GraphicApplication/src/canvas/backends/nanovg/private/CanvasSurface.cpp create mode 100644 GraphicApplication/src/canvas/backends/nanovg/private/NanoVgCanvasBackend.cpp create mode 100644 GraphicApplication/src/canvas/backends/nanovg/private/NanoVgCanvasBackend.hpp create mode 100644 GraphicApplication/src/canvas/backends/recording/private/RecordingCanvasBackend.cpp create mode 100644 GraphicApplication/src/canvas/backends/recording/public/RecordingCanvasBackend.hpp delete mode 100644 GraphicApplication/src/canvas/private/CanvasContext.hpp delete mode 100644 GraphicApplication/src/canvas/private/CanvasImages.cpp create mode 100644 GraphicApplication/tests/CanvasTests.cpp diff --git a/GraphicApplication/CMakeLists.txt b/GraphicApplication/CMakeLists.txt index ccfd36a4..768ed6f6 100644 --- a/GraphicApplication/CMakeLists.txt +++ b/GraphicApplication/CMakeLists.txt @@ -87,13 +87,25 @@ target_link_libraries(${PROJECT_NAME}Backend PUBLIC ${PROJECT_NAME}BackendContra graphic_application_library(${PROJECT_NAME}Window window) target_link_libraries(${PROJECT_NAME}Window PUBLIC Math ${PROJECT_NAME}BackendContract PRIVATE ${PROJECT_NAME}Backend) -### ---------------------- Canvas --------------------- ### +### ---------------------- Canvas contract (what a canvas backend implements) --------------------- ### +graphic_application_library(${PROJECT_NAME}CanvasContract canvas/backends/contract) +target_link_libraries(${PROJECT_NAME}CanvasContract PUBLIC Math) + +### ---------------------- Canvas (portable drawing facade) --------------------- ### graphic_application_library(${PROJECT_NAME}Canvas canvas) -target_link_libraries(${PROJECT_NAME}Canvas PUBLIC Math Containers ${PROJECT_NAME}BackendContract PRIVATE ${PROJECT_NAME}Diagnostics NanovgVk) +target_link_libraries(${PROJECT_NAME}Canvas PUBLIC ${PROJECT_NAME}CanvasContract Containers) + +### ---------------------- Recording canvas backend (headless tests) --------------------- ### +graphic_application_library(${PROJECT_NAME}CanvasRecording canvas/backends/recording) +target_link_libraries(${PROJECT_NAME}CanvasRecording PUBLIC ${PROJECT_NAME}CanvasContract) + +### ---------------------- NanoVG canvas backend (window surfaces) --------------------- ### +graphic_application_library(${PROJECT_NAME}CanvasNanoVg canvas/backends/nanovg) +target_link_libraries(${PROJECT_NAME}CanvasNanoVg PUBLIC ${PROJECT_NAME}Canvas ${PROJECT_NAME}BackendContract PRIVATE ${PROJECT_NAME}Diagnostics NanovgVk) ### ---------------------- Window decorations --------------------- ### graphic_application_library(${PROJECT_NAME}Decorations decorations) -target_link_libraries(${PROJECT_NAME}Decorations PUBLIC ${PROJECT_NAME}Canvas) +target_link_libraries(${PROJECT_NAME}Decorations PUBLIC ${PROJECT_NAME}Canvas ${PROJECT_NAME}BackendContract) ### ---------------------- Debug UI --------------------- ### graphic_application_library(${PROJECT_NAME}DebugUI debug_ui) @@ -101,7 +113,7 @@ target_link_libraries(${PROJECT_NAME}DebugUI PUBLIC ${PROJECT_NAME}Window PRIVAT ### ---------------------- Application --------------------- ### graphic_application_library(${PROJECT_NAME} application) -target_link_libraries(${PROJECT_NAME} PUBLIC ${PROJECT_NAME}Window ${PROJECT_NAME}Canvas ${PROJECT_NAME}Decorations ${PROJECT_NAME}DebugUI ${PROJECT_NAME}Diagnostics) +target_link_libraries(${PROJECT_NAME} PUBLIC ${PROJECT_NAME}Window ${PROJECT_NAME}CanvasNanoVg ${PROJECT_NAME}Decorations ${PROJECT_NAME}DebugUI ${PROJECT_NAME}Diagnostics) target_compile_definitions(${PROJECT_NAME} PRIVATE MODULES_GENERIC_APP_ID="online.ilushabasic.modules" MODULES_GENERIC_APP_NAME="Modules") # Copies the shared fonts into an "rsc" directory next to an executable, where the desktop backend looks for them. @@ -126,8 +138,8 @@ endif () if (NOT ANDROID) file(GLOB TEST_SOURCES CONFIGURE_DEPENDS "./tests/*.cpp") add_executable(Test${PROJECT_NAME} ${TEST_SOURCES}) - target_include_directories(Test${PROJECT_NAME} PRIVATE ./src/canvas/private ./src/backends/wayland/private) + target_include_directories(Test${PROJECT_NAME} PRIVATE ./src/canvas/backends/nanovg/private ./src/backends/wayland/private) target_compile_definitions(Test${PROJECT_NAME} PRIVATE MODULES_SOURCE_DIR="${CMAKE_SOURCE_DIR}") - target_link_libraries(Test${PROJECT_NAME} ${PROJECT_NAME}Window ${PROJECT_NAME}Backend ${PROJECT_NAME}Diagnostics NanovgVk UnitTest++) + target_link_libraries(Test${PROJECT_NAME} ${PROJECT_NAME}Window ${PROJECT_NAME}Backend ${PROJECT_NAME}Diagnostics ${PROJECT_NAME}Canvas ${PROJECT_NAME}CanvasRecording NanovgVk UnitTest++) add_test(NAME Test${PROJECT_NAME} COMMAND Test${PROJECT_NAME}) endif () diff --git a/GraphicApplication/src/application/private/GraphicApplication.cpp b/GraphicApplication/src/application/private/GraphicApplication.cpp index 30df86be..cd344bcb 100644 --- a/GraphicApplication/src/application/private/GraphicApplication.cpp +++ b/GraphicApplication/src/application/private/GraphicApplication.cpp @@ -132,13 +132,13 @@ void Application::present() { return; } - mCanvas->drawBegin(*frame, mWindow->frame()); + mCanvas->beginFrame(*frame, mWindow->frame()); mDebugGUI->drawBegin(); drawFrameContent(); enterPhase(UiPhase::Present); - mCanvas->drawEnd(); + mCanvas->endFrame(); mDebugGUI->drawEnd(*frame); mWindow->loop().frameEnd(); @@ -149,12 +149,13 @@ void Application::drawFrameContent() { const ProfileScope scope(mProfiler, mDrawSection); const WindowFrame& frame = mWindow->frame(); - mDecorations.paintBase(*mCanvas, frame); + Canvas& canvas = *mCanvas; + mDecorations.paintBase(canvas, frame); { - const Canvas::SavedState saved(*mCanvas); - mDelegate.drawFrame(*mCanvas); + const Canvas::SavedState saved(canvas); + mDelegate.drawFrame(canvas); } - mDecorations.paintFrame(*mCanvas, frame, mWindow->controls().isFocused()); + mDecorations.paintFrame(canvas, frame, mWindow->controls().isFocused()); } void Application::updateFrameCounters() { diff --git a/GraphicApplication/src/canvas/backends/contract/public/CanvasBackend.hpp b/GraphicApplication/src/canvas/backends/contract/public/CanvasBackend.hpp new file mode 100644 index 00000000..68871508 --- /dev/null +++ b/GraphicApplication/src/canvas/backends/contract/public/CanvasBackend.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include "CanvasTypes.hpp" + +#include +#include + +namespace tp { + + // What a canvas draws with. Every coordinate is already in surface space: the canvas has applied its origin, and the + // clip it passes is the intersection of every open clip scope. + class CanvasBackend : public TextMetrics { + public: + enum class CompositeOperation { SourceOver, DestinationOut, DestinationOver }; + enum class PathWinding { Solid, Hole }; + + struct LinearGradient { + Vec2F from; + Vec2F to; + RGBA fromColor; + RGBA toColor; + }; + + struct BoxGradient { + RectF box; + halnf radius = 0; + halnf feather = 0; + RGBA innerColor; + RGBA outerColor; + }; + + virtual void setClip(std::optional clip) = 0; + virtual void setCompositeOperation(CompositeOperation operation) = 0; + + virtual void beginPath() = 0; + virtual void addPathRect(const RectF& rect, halnf round) = 0; + virtual void setPathWinding(PathWinding winding) = 0; + virtual void fillPath(const RGBA& color) = 0; + virtual void fillPath(const BoxGradient& gradient) = 0; + + virtual void rect(const RectF& rect, const RGBA& color, halnf round) = 0; + virtual void gradientRect(const RectF& rect, const RGBA& from, const RGBA& to, Axis axis, halnf round) = 0; + virtual void outline(const RectF& rect, const RGBA& color, halnf round, halnf width) = 0; + virtual void circle(Vec2F center, halnf radius, const RGBA& color) = 0; + virtual void circleOutline(Vec2F center, halnf radius, const RGBA& color, halnf width) = 0; + virtual void line(Vec2F start, Vec2F end, const RGBA& color, halnf thickness) = 0; + virtual void polyline(const Vec2F* points, int count, const RGBA& color, halnf width) = 0; + virtual void polygon(const Vec2F* points, int count, const RGBA& color) = 0; + virtual void polygon(const Vec2F* points, int count, const LinearGradient& gradient) = 0; + virtual void ringSegment( + Vec2F center, halnf innerRadius, halnf outerRadius, halnf fromAngle, halnf toAngle, const LinearGradient& gradient + ) = 0; + virtual void + radialGlow(Vec2F center, halnf innerRadius, halnf outerRadius, const RGBA& innerColor, const RGBA& outerColor) = 0; + + virtual void text(const std::string& text, const RectF& area, const TextStyle& style) = 0; + virtual void rotatedText(const std::string& text, Vec2F center, halnf size, halnf angle, const RGBA& color) = 0; + + [[nodiscard]] virtual ImageHandle createImageFromRenderTarget(uint32_t renderTargetId) = 0; + virtual void updateImageFromRenderTarget(ImageHandle image, uint32_t renderTargetId) = 0; + [[nodiscard]] virtual ImageHandle createImageFromFile(const char* path) = 0; + [[nodiscard]] virtual ImageHandle createImageFromMemoryRGBA(int width, int height, const unsigned char* rgba) = 0; + virtual void updateImageRGBA(ImageHandle image, const unsigned char* rgba) = 0; + [[nodiscard]] virtual Vec2F imageSize(ImageHandle image) = 0; + virtual void deleteImage(ImageHandle image) = 0; + virtual void drawImage(const RectF& rect, ImageHandle image, halnf angle, halnf alpha, halnf rounding) = 0; + + protected: + [[nodiscard]] static ImageHandle makeImageHandle(int id) { return ImageHandle(id); } + }; +} diff --git a/GraphicApplication/src/canvas/backends/contract/public/CanvasTypes.hpp b/GraphicApplication/src/canvas/backends/contract/public/CanvasTypes.hpp new file mode 100644 index 00000000..74c2ffce --- /dev/null +++ b/GraphicApplication/src/canvas/backends/contract/public/CanvasTypes.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "Color.hpp" +#include "Rect.hpp" + +#include +#include + +namespace tp { + + enum class Axis { Horizontal = 0, Vertical = 1 }; + + enum class HorizontalAlign { Left, Center, Right }; + + enum class VerticalAlign { Top, Center, Bottom }; + + struct TextAlign { + HorizontalAlign horizontal = HorizontalAlign::Left; + VerticalAlign vertical = VerticalAlign::Center; + + static constexpr TextAlign leftCenter() { return { HorizontalAlign::Left, VerticalAlign::Center }; } + static constexpr TextAlign centered() { return { HorizontalAlign::Center, VerticalAlign::Center }; } + static constexpr TextAlign rightCenter() { return { HorizontalAlign::Right, VerticalAlign::Center }; } + + [[nodiscard]] bool operator==(const TextAlign& other) const = default; + }; + + enum class FontFace { Regular, Bold, Italic, BoldItalic, Monospace }; + + struct TextStyle { + halnf size = 16; + RGBA color = RGBA(1, 1, 1, 1); + TextAlign align; + halnf padding = 0; + FontFace face = FontFace::Regular; + }; + + class ImageHandle { + public: + ImageHandle() = default; + + [[nodiscard]] bool isValid() const { return mId > 0; } + [[nodiscard]] int id() const { return mId; } + [[nodiscard]] bool operator==(const ImageHandle& other) const = default; + + private: + friend class Canvas; + friend class CanvasBackend; + + explicit ImageHandle(int id) : + mId(id) {} + + int mId = 0; + }; + + // Measures text the way a canvas draws it; layout uses it without a GPU frame. + class TextMetrics { + public: + virtual ~TextMetrics() = default; + + [[nodiscard]] virtual halnf textWidth(const std::string& text, halnf size, FontFace face) const = 0; + + // Pen position before every byte of the text and after its end (text.size() + 1 values). + [[nodiscard]] virtual std::vector prefixWidths(const std::string& text, halnf size, FontFace face) const = 0; + }; +} diff --git a/GraphicApplication/src/canvas/private/CanvasFontLoader.cpp b/GraphicApplication/src/canvas/backends/nanovg/private/CanvasFontLoader.cpp similarity index 100% rename from GraphicApplication/src/canvas/private/CanvasFontLoader.cpp rename to GraphicApplication/src/canvas/backends/nanovg/private/CanvasFontLoader.cpp diff --git a/GraphicApplication/src/canvas/private/CanvasFontLoader.hpp b/GraphicApplication/src/canvas/backends/nanovg/private/CanvasFontLoader.hpp similarity index 100% rename from GraphicApplication/src/canvas/private/CanvasFontLoader.hpp rename to GraphicApplication/src/canvas/backends/nanovg/private/CanvasFontLoader.hpp diff --git a/GraphicApplication/src/canvas/private/CanvasFonts.hpp b/GraphicApplication/src/canvas/backends/nanovg/private/CanvasFonts.hpp similarity index 100% rename from GraphicApplication/src/canvas/private/CanvasFonts.hpp rename to GraphicApplication/src/canvas/backends/nanovg/private/CanvasFonts.hpp diff --git a/GraphicApplication/src/canvas/backends/nanovg/private/CanvasSurface.cpp b/GraphicApplication/src/canvas/backends/nanovg/private/CanvasSurface.cpp new file mode 100644 index 00000000..aa317162 --- /dev/null +++ b/GraphicApplication/src/canvas/backends/nanovg/private/CanvasSurface.cpp @@ -0,0 +1,26 @@ +#include "Canvas.hpp" + +#include "Assert.hpp" +#include "NanoVgCanvasBackend.hpp" + +using namespace tp; + +// The parts of the canvas facade that only exist on a window surface. They live here, next to the NanoVG backend, so +// the portable canvas library never names a GPU frame or a window. + +Canvas::Canvas(WindowSurface& surface, Profiler& profiler, const std::filesystem::path& resourceDirectory) { + auto backend = std::make_unique(surface, profiler, resourceDirectory); + mSurfaceBackend = backend.get(); + mBackend = std::move(backend); +} + +void Canvas::beginFrame(const GpuFrame& frame, const WindowFrame& windowFrame) { + DEBUG_ASSERT(mSurfaceBackend); + mSurfaceBackend->beginFrame(frame, windowFrame); + beginFrame(); +} + +void Canvas::endFrame() { + DEBUG_ASSERT(mSurfaceBackend); + mSurfaceBackend->endFrame(); +} diff --git a/GraphicApplication/src/canvas/backends/nanovg/private/NanoVgCanvasBackend.cpp b/GraphicApplication/src/canvas/backends/nanovg/private/NanoVgCanvasBackend.cpp new file mode 100644 index 00000000..c5722ca8 --- /dev/null +++ b/GraphicApplication/src/canvas/backends/nanovg/private/NanoVgCanvasBackend.cpp @@ -0,0 +1,407 @@ +#include "NanoVgCanvasBackend.hpp" + +#include "GpuError.hpp" +#include "RenderTargetRegistry.hpp" +#include "Profiler.hpp" + +#include + +#include + +using namespace tp; + +namespace { + constexpr halnf kItalicSkew = -0.21f; + + bool isItalic(FontFace face) { return face == FontFace::Italic || face == FontFace::BoldItalic; } + + void addRect(NVGcontext* vg, const RectF& rect, halnf round) { + if (round == 0) { + nvgRect(vg, rect.x, rect.y, rect.z, rect.w); + } else { + nvgRoundedRect(vg, rect.x, rect.y, rect.z, rect.w, round); + } + } + + void stroke(NVGcontext* vg, const RGBA& color, halnf width) { + nvgStrokeWidth(vg, width); + nvgStrokeColor(vg, toNVGColor(color)); + nvgStroke(vg); + } + + void fill(NVGcontext* vg, const RGBA& color) { + nvgFillColor(vg, toNVGColor(color)); + nvgFill(vg); + } + + void addPoints(NVGcontext* vg, const Vec2F* points, int count) { + nvgMoveTo(vg, points[0].x, points[0].y); + for (int index = 1; index < count; index++) { + nvgLineTo(vg, points[index].x, points[index].y); + } + } + + NVGpaint linearPaint(NVGcontext* vg, const CanvasBackend::LinearGradient& gradient) { + return nvgLinearGradient( + vg, + gradient.from.x, + gradient.from.y, + gradient.to.x, + gradient.to.y, + toNVGColor(gradient.fromColor), + toNVGColor(gradient.toColor) + ); + } + + int nvgCompositeOperationOf(CanvasBackend::CompositeOperation operation) { + switch (operation) { + case CanvasBackend::CompositeOperation::SourceOver: return NVG_SOURCE_OVER; + case CanvasBackend::CompositeOperation::DestinationOut: return NVG_DESTINATION_OUT; + case CanvasBackend::CompositeOperation::DestinationOver: return NVG_DESTINATION_OVER; + } + return NVG_SOURCE_OVER; + } + + int nvgWindingOf(CanvasBackend::PathWinding winding) { + return winding == CanvasBackend::PathWinding::Hole ? NVG_HOLE : NVG_SOLID; + } + + int nvgAlignmentOf(const TextAlign& align) { + int alignment = 0; + switch (align.horizontal) { + case HorizontalAlign::Left: alignment |= NVG_ALIGN_LEFT; break; + case HorizontalAlign::Center: alignment |= NVG_ALIGN_CENTER; break; + case HorizontalAlign::Right: alignment |= NVG_ALIGN_RIGHT; break; + } + switch (align.vertical) { + case VerticalAlign::Top: alignment |= NVG_ALIGN_TOP; break; + case VerticalAlign::Center: alignment |= NVG_ALIGN_MIDDLE; break; + case VerticalAlign::Bottom: alignment |= NVG_ALIGN_BOTTOM; break; + } + return alignment; + } + + Vec2F textAnchor(const RectF& rect, const TextAlign& align) { + Vec2F anchor = rect.pos; + if (align.horizontal == HorizontalAlign::Center) { + anchor.x += rect.z * 0.5f; + } + if (align.horizontal == HorizontalAlign::Right) { + anchor.x += rect.z; + } + if (align.vertical == VerticalAlign::Center) { + anchor.y += rect.w * 0.5f; + } + if (align.vertical == VerticalAlign::Bottom) { + anchor.y += rect.w; + } + return anchor; + } +} + +NanoVgCanvasBackend::NanoVgCanvasBackend( + WindowSurface& surface, Profiler& profiler, const std::filesystem::path& resourceDirectory +) : + mSurface(surface), + mProfiler(profiler), + mFlushSection(profiler.section("frame.ui_flush")) { + const GpuDevice& gpu = surface.gpu(); + VKNVGCreateInfo createInfo{}; + createInfo.gpu = gpu.physicalDevice(); + createInfo.device = gpu.device(); + createInfo.renderpass = surface.renderPass(); + createInfo.cmdBuffer = mFrameCommandBuffers; + createInfo.frameCount = WindowSurface::kFramesInFlight; + createInfo.currentFrame = &mFrameSlot; + createInfo.commandPool = gpu.commandPool(); + createInfo.graphicsQueueFamilyIndex = gpu.graphicsQueueFamily(); + + mVg = nvgCreateVk(&createInfo, NVG_ANTIALIAS | NVG_STENCIL_STROKES, gpu.graphicsQueue()); + if (!mVg) { + throw GpuError("creating the NanoVG context", VK_ERROR_INITIALIZATION_FAILED); + } + mFonts = loadCanvasFonts(mVg, resourceDirectory); +} + +NanoVgCanvasBackend::~NanoVgCanvasBackend() { + mSurface.gpu().waitIdle(); + nvgDeleteVk(mVg); +} + +void NanoVgCanvasBackend::beginFrame(const GpuFrame& frame, const WindowFrame& windowFrame) { + mFrameCommandBuffers[frame.slot] = frame.commandBuffer; + mFrameSlot = frame.slot; + + const Vec2F surfaceSize = windowFrame.surfaceSize(); + const halnf devicePixelRatio = surfaceSize.x > 0 ? (halnf) frame.extent.width / surfaceSize.x : 1.0f; + nvgBeginFrame(mVg, surfaceSize.x, surfaceSize.y, devicePixelRatio); + nvgTranslate(mVg, windowFrame.margin(), windowFrame.margin()); +} + +void NanoVgCanvasBackend::endFrame() { + const ProfileScope scope(mProfiler, mFlushSection); + nvgEndFrame(mVg); +} + +void NanoVgCanvasBackend::setClip(std::optional clip) { + if (!clip) { + nvgResetScissor(mVg); + return; + } + nvgScissor(mVg, clip->x, clip->y, clip->z, clip->w); +} + +void NanoVgCanvasBackend::setCompositeOperation(CompositeOperation operation) { + nvgGlobalCompositeOperation(mVg, nvgCompositeOperationOf(operation)); +} + +void NanoVgCanvasBackend::beginPath() { nvgBeginPath(mVg); } + +void NanoVgCanvasBackend::addPathRect(const RectF& rect, halnf round) { addRect(mVg, rect, round); } + +void NanoVgCanvasBackend::setPathWinding(PathWinding winding) { nvgPathWinding(mVg, nvgWindingOf(winding)); } + +void NanoVgCanvasBackend::fillPath(const RGBA& color) { fill(mVg, color); } + +void NanoVgCanvasBackend::fillPath(const BoxGradient& gradient) { + const RectF& box = gradient.box; + const NVGpaint paint = nvgBoxGradient( + mVg, + box.x, + box.y, + box.z, + box.w, + gradient.radius, + gradient.feather, + toNVGColor(gradient.innerColor), + toNVGColor(gradient.outerColor) + ); + nvgFillPaint(mVg, paint); + nvgFill(mVg); +} + +void NanoVgCanvasBackend::rect(const RectF& rect, const RGBA& color, halnf round) { + nvgBeginPath(mVg); + addRect(mVg, rect, round); + fill(mVg, color); +} + +void NanoVgCanvasBackend::gradientRect(const RectF& rect, const RGBA& from, const RGBA& to, Axis axis, halnf round) { + const bool horizontal = axis == Axis::Horizontal; + const halnf endX = horizontal ? rect.x + rect.z : rect.x; + const halnf endY = horizontal ? rect.y : rect.y + rect.w; + const NVGpaint paint = nvgLinearGradient(mVg, rect.x, rect.y, endX, endY, toNVGColor(from), toNVGColor(to)); + nvgBeginPath(mVg); + addRect(mVg, rect, round); + nvgFillPaint(mVg, paint); + nvgFill(mVg); +} + +void NanoVgCanvasBackend::outline(const RectF& rect, const RGBA& color, halnf round, halnf width) { + const halnf inset = width / 2; + nvgBeginPath(mVg); + nvgRoundedRect( + mVg, rect.x + inset, rect.y + inset, rect.z - width, rect.w - width, round > inset ? round - inset : 0 + ); + stroke(mVg, color, width); +} + +void NanoVgCanvasBackend::circle(Vec2F center, halnf radius, const RGBA& color) { + nvgBeginPath(mVg); + nvgCircle(mVg, center.x, center.y, radius); + fill(mVg, color); +} + +void NanoVgCanvasBackend::circleOutline(Vec2F center, halnf radius, const RGBA& color, halnf width) { + nvgBeginPath(mVg); + nvgCircle(mVg, center.x, center.y, radius - width / 2); + stroke(mVg, color, width); +} + +void NanoVgCanvasBackend::line(Vec2F start, Vec2F end, const RGBA& color, halnf thickness) { + nvgBeginPath(mVg); + nvgMoveTo(mVg, start.x, start.y); + nvgLineTo(mVg, end.x, end.y); + stroke(mVg, color, thickness); +} + +void NanoVgCanvasBackend::polyline(const Vec2F* points, int count, const RGBA& color, halnf width) { + if (count < 2) { + return; + } + + nvgBeginPath(mVg); + addPoints(mVg, points, count); + nvgLineCap(mVg, NVG_ROUND); + nvgLineJoin(mVg, NVG_ROUND); + stroke(mVg, color, width); +} + +void NanoVgCanvasBackend::polygon(const Vec2F* points, int count, const RGBA& color) { + if (count < 3) { + return; + } + + nvgBeginPath(mVg); + addPoints(mVg, points, count); + nvgClosePath(mVg); + fill(mVg, color); +} + +void NanoVgCanvasBackend::polygon(const Vec2F* points, int count, const LinearGradient& gradient) { + if (count < 3) { + return; + } + + nvgBeginPath(mVg); + addPoints(mVg, points, count); + nvgClosePath(mVg); + nvgFillPaint(mVg, linearPaint(mVg, gradient)); + nvgFill(mVg); +} + +void NanoVgCanvasBackend::ringSegment( + Vec2F center, halnf innerRadius, halnf outerRadius, halnf fromAngle, halnf toAngle, const LinearGradient& gradient +) { + nvgBeginPath(mVg); + nvgArc(mVg, center.x, center.y, innerRadius, fromAngle, toAngle, NVG_CW); + nvgArc(mVg, center.x, center.y, outerRadius, toAngle, fromAngle, NVG_CCW); + nvgClosePath(mVg); + nvgFillPaint(mVg, linearPaint(mVg, gradient)); + nvgFill(mVg); +} + +void NanoVgCanvasBackend::radialGlow( + Vec2F center, halnf innerRadius, halnf outerRadius, const RGBA& innerColor, const RGBA& outerColor +) { + const NVGpaint paint = nvgRadialGradient( + mVg, center.x, center.y, innerRadius, outerRadius, toNVGColor(innerColor), toNVGColor(outerColor) + ); + + nvgBeginPath(mVg); + nvgCircle(mVg, center.x, center.y, outerRadius); + nvgFillPaint(mVg, paint); + nvgFill(mVg); +} + +void NanoVgCanvasBackend::text(const std::string& string, const RectF& area, const TextStyle& style) { + nvgSave(mVg); + nvgFontSize(mVg, style.size); + nvgFontFaceId(mVg, mFonts.idFor(style.face)); + nvgFillColor(mVg, toNVGColor(style.color)); + nvgTextAlign(mVg, nvgAlignmentOf(style.align)); + + const Vec2F anchor = textAnchor(area, style.align); + if (isItalic(style.face)) { + nvgTranslate(mVg, anchor.x, anchor.y); + nvgSkewX(mVg, kItalicSkew); + nvgText(mVg, 0, 0, string.c_str(), nullptr); + } else { + nvgText(mVg, anchor.x, anchor.y, string.c_str(), nullptr); + } + nvgRestore(mVg); +} + +void NanoVgCanvasBackend::rotatedText( + const std::string& string, Vec2F center, halnf size, halnf angle, const RGBA& color +) { + nvgSave(mVg); + nvgTranslate(mVg, center.x, center.y); + nvgRotate(mVg, angle); + nvgFontSize(mVg, size); + nvgFontFaceId(mVg, mFonts.regular); + nvgTextAlign(mVg, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE); + nvgFillColor(mVg, toNVGColor(color)); + nvgText(mVg, 0, 0, string.c_str(), nullptr); + nvgRestore(mVg); +} + +halnf NanoVgCanvasBackend::textWidth(const std::string& string, halnf size, FontFace face) const { + nvgFontSize(mVg, size); + nvgFontFaceId(mVg, mFonts.idFor(face)); + return nvgTextBounds(mVg, 0, 0, string.c_str(), nullptr, nullptr); +} + +std::vector NanoVgCanvasBackend::prefixWidths(const std::string& string, halnf size, FontFace face) const { + const size_t length = string.size(); + std::vector widths(length + 1, 0); + + nvgFontSize(mVg, size); + nvgFontFaceId(mVg, mFonts.idFor(face)); + nvgTextAlign(mVg, NVG_ALIGN_LEFT | NVG_ALIGN_TOP); + + const char* start = string.c_str(); + std::vector glyphs(length); + const int count = nvgTextGlyphPositions(mVg, 0, 0, start, start + length, glyphs.data(), (int) length); + + size_t byte = 0; + for (int index = 0; index < count; index++) { + const size_t glyphStart = (size_t) (glyphs[(size_t) index].str - start); + const halnf pen = glyphs[(size_t) index].x; + for (; byte <= glyphStart && byte < length; byte++) { + widths[byte] = pen; + } + } + const halnf total = nvgTextBounds(mVg, 0, 0, start, start + length, nullptr); + for (; byte < length; byte++) { + widths[byte] = total; + } + widths[length] = total; + return widths; +} + +void NanoVgCanvasBackend::drawImage(const RectF& rect, ImageHandle image, halnf angle, halnf alpha, halnf rounding) { + const NVGpaint paint = nvgImagePattern(mVg, rect.x, rect.y, rect.z, rect.w, angle, image.id(), alpha); + nvgBeginPath(mVg); + nvgRoundedRect(mVg, rect.x, rect.y, rect.z, rect.w, rounding); + nvgFillPaint(mVg, paint); + nvgFill(mVg); +} +ImageHandle NanoVgCanvasBackend::createImageFromRenderTarget(uint32_t renderTargetId) { + RenderTargetEntry entry; + if (!mSurface.gpu().renderTargets().resolve(renderTargetId, entry)) { + return {}; + } + const int image = nvgCreateImageFromHandleVk( + mVg, entry.image, entry.view, entry.sampler, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, entry.width, entry.height, 0 + ); + return makeImageHandle(image); +} + +void NanoVgCanvasBackend::updateImageFromRenderTarget(ImageHandle image, uint32_t renderTargetId) { + RenderTargetEntry entry; + if (!mSurface.gpu().renderTargets().resolve(renderTargetId, entry)) { + return; + } + nvgUpdateImageFromHandleVk( + mVg, + image.id(), + entry.image, + entry.view, + entry.sampler, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + entry.width, + entry.height + ); +} + +ImageHandle NanoVgCanvasBackend::createImageFromFile(const char* path) { + return makeImageHandle(nvgCreateImage(mVg, path, 0)); +} + +ImageHandle NanoVgCanvasBackend::createImageFromMemoryRGBA(int width, int height, const unsigned char* rgba) { + return makeImageHandle(nvgCreateImageRGBA(mVg, width, height, 0, rgba)); +} + +void NanoVgCanvasBackend::updateImageRGBA(ImageHandle image, const unsigned char* rgba) { + nvgUpdateImage(mVg, image.id(), rgba); +} + +Vec2F NanoVgCanvasBackend::imageSize(ImageHandle image) { + int width = 0; + int height = 0; + nvgImageSize(mVg, image.id(), &width, &height); + return { (halnf) width, (halnf) height }; +} + +void NanoVgCanvasBackend::deleteImage(ImageHandle image) { nvgDeleteImage(mVg, image.id()); } diff --git a/GraphicApplication/src/canvas/backends/nanovg/private/NanoVgCanvasBackend.hpp b/GraphicApplication/src/canvas/backends/nanovg/private/NanoVgCanvasBackend.hpp new file mode 100644 index 00000000..9cef5ff0 --- /dev/null +++ b/GraphicApplication/src/canvas/backends/nanovg/private/NanoVgCanvasBackend.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "CanvasBackend.hpp" +#include "CanvasFontLoader.hpp" +#include "WindowFrame.hpp" +#include "WindowSurface.hpp" + +#include + +#include + +namespace tp { + + class Profiler; + + // Draws a canvas onto a window surface with NanoVG on Vulkan. + class NanoVgCanvasBackend final : public CanvasBackend { + public: + NanoVgCanvasBackend(WindowSurface& surface, Profiler& profiler, const std::filesystem::path& resourceDirectory); + ~NanoVgCanvasBackend() override; + + NanoVgCanvasBackend(const NanoVgCanvasBackend&) = delete; + NanoVgCanvasBackend& operator=(const NanoVgCanvasBackend&) = delete; + + void beginFrame(const GpuFrame& frame, const WindowFrame& windowFrame); + void endFrame(); + + void setClip(std::optional clip) override; + void setCompositeOperation(CompositeOperation operation) override; + + void beginPath() override; + void addPathRect(const RectF& rect, halnf round) override; + void setPathWinding(PathWinding winding) override; + void fillPath(const RGBA& color) override; + void fillPath(const BoxGradient& gradient) override; + + void rect(const RectF& rect, const RGBA& color, halnf round) override; + void gradientRect(const RectF& rect, const RGBA& from, const RGBA& to, Axis axis, halnf round) override; + void outline(const RectF& rect, const RGBA& color, halnf round, halnf width) override; + void circle(Vec2F center, halnf radius, const RGBA& color) override; + void circleOutline(Vec2F center, halnf radius, const RGBA& color, halnf width) override; + void line(Vec2F start, Vec2F end, const RGBA& color, halnf thickness) override; + void polyline(const Vec2F* points, int count, const RGBA& color, halnf width) override; + void polygon(const Vec2F* points, int count, const RGBA& color) override; + void polygon(const Vec2F* points, int count, const LinearGradient& gradient) override; + void ringSegment( + Vec2F center, halnf innerRadius, halnf outerRadius, halnf fromAngle, halnf toAngle, const LinearGradient& gradient + ) override; + void radialGlow( + Vec2F center, halnf innerRadius, halnf outerRadius, const RGBA& innerColor, const RGBA& outerColor + ) override; + + void text(const std::string& text, const RectF& area, const TextStyle& style) override; + void rotatedText(const std::string& text, Vec2F center, halnf size, halnf angle, const RGBA& color) override; + + [[nodiscard]] halnf textWidth(const std::string& text, halnf size, FontFace face) const override; + [[nodiscard]] std::vector prefixWidths(const std::string& text, halnf size, FontFace face) const override; + + [[nodiscard]] ImageHandle createImageFromRenderTarget(uint32_t renderTargetId) override; + void updateImageFromRenderTarget(ImageHandle image, uint32_t renderTargetId) override; + [[nodiscard]] ImageHandle createImageFromFile(const char* path) override; + [[nodiscard]] ImageHandle createImageFromMemoryRGBA(int width, int height, const unsigned char* rgba) override; + void updateImageRGBA(ImageHandle image, const unsigned char* rgba) override; + [[nodiscard]] Vec2F imageSize(ImageHandle image) override; + void deleteImage(ImageHandle image) override; + void drawImage(const RectF& rect, ImageHandle image, halnf angle, halnf alpha, halnf rounding) override; + + private: + NVGcontext* mVg = nullptr; + WindowSurface& mSurface; + Profiler& mProfiler; + uint32_t mFlushSection = 0; + CanvasFontIds mFonts; + + // NanoVG reads the recording command buffer and frame slot through these pointers on every flush. + VkCommandBuffer mFrameCommandBuffers[WindowSurface::kFramesInFlight] = {}; + uint32_t mFrameSlot = 0; + }; + + inline NVGcolor toNVGColor(const RGBA& color) { return { color.r, color.g, color.b, color.a }; } +} diff --git a/GraphicApplication/src/canvas/backends/recording/private/RecordingCanvasBackend.cpp b/GraphicApplication/src/canvas/backends/recording/private/RecordingCanvasBackend.cpp new file mode 100644 index 00000000..61ef7f2d --- /dev/null +++ b/GraphicApplication/src/canvas/backends/recording/private/RecordingCanvasBackend.cpp @@ -0,0 +1,211 @@ +#include "RecordingCanvasBackend.hpp" + +#include + +using namespace tp; + +namespace { + RectF boundsOf(const Vec2F* points, int count) { + if (count <= 0) { + return { 0, 0, 0, 0 }; + } + Vec2F low = points[0]; + Vec2F high = points[0]; + for (int index = 1; index < count; index++) { + low = { std::min(low.x, points[index].x), std::min(low.y, points[index].y) }; + high = { std::max(high.x, points[index].x), std::max(high.y, points[index].y) }; + } + return { low, high - low }; + } + + RectF squareAround(Vec2F center, halnf radius) { + return { center - Vec2F(radius, radius), Vec2F(radius, radius) * 2 }; + } +} + +void RecordingCanvasBackend::setClip(std::optional clip) { mClip = clip; } + +CanvasCommand& RecordingCanvasBackend::record(CanvasCommand::Kind kind, const RectF& rect, const RGBA& color) { + CanvasCommand command; + command.kind = kind; + command.rect = rect; + command.color = color; + command.clipped = mClip.has_value(); + command.clip = mClip.value_or(RectF(-1.0e9f, -1.0e9f, 2.0e9f, 2.0e9f)); + command.composite = mComposite; + mCommands.push_back(std::move(command)); + return mCommands.back(); +} + +void RecordingCanvasBackend::recordPoints( + CanvasCommand::Kind kind, const Vec2F* points, int count, const RGBA& color, halnf width +) { + CanvasCommand& command = record(kind, boundsOf(points, count), color); + command.width = width; + command.points.assign(points, points + std::max(count, 0)); +} + +void RecordingCanvasBackend::beginPath() { mPathRects.clear(); } + +void RecordingCanvasBackend::addPathRect(const RectF& rect, halnf) { mPathRects.push_back(rect); } + +void RecordingCanvasBackend::fillPath(const RGBA& color) { + for (const RectF& rect : mPathRects) { + record(CanvasCommand::Kind::Path, rect, color); + } +} + +void RecordingCanvasBackend::fillPath(const BoxGradient& gradient) { + CanvasCommand& command = record(CanvasCommand::Kind::Path, gradient.box, gradient.innerColor); + command.endColor = gradient.outerColor; + command.round = gradient.radius; +} + +void RecordingCanvasBackend::rect(const RectF& rect, const RGBA& color, halnf round) { + record(CanvasCommand::Kind::Rect, rect, color).round = round; +} + +void RecordingCanvasBackend::gradientRect(const RectF& rect, const RGBA& from, const RGBA& to, Axis, halnf round) { + CanvasCommand& command = record(CanvasCommand::Kind::Gradient, rect, from); + command.endColor = to; + command.round = round; +} + +void RecordingCanvasBackend::outline(const RectF& rect, const RGBA& color, halnf round, halnf width) { + CanvasCommand& command = record(CanvasCommand::Kind::Outline, rect, color); + command.round = round; + command.width = width; +} + +void RecordingCanvasBackend::circle(Vec2F center, halnf radius, const RGBA& color) { + record(CanvasCommand::Kind::Circle, squareAround(center, radius), color); +} + +void RecordingCanvasBackend::circleOutline(Vec2F center, halnf radius, const RGBA& color, halnf width) { + record(CanvasCommand::Kind::CircleOutline, squareAround(center, radius), color).width = width; +} + +void RecordingCanvasBackend::line(Vec2F start, Vec2F end, const RGBA& color, halnf thickness) { + const Vec2F points[] = { start, end }; + recordPoints(CanvasCommand::Kind::Line, points, 2, color, thickness); +} + +void RecordingCanvasBackend::polyline(const Vec2F* points, int count, const RGBA& color, halnf width) { + recordPoints(CanvasCommand::Kind::Polyline, points, count, color, width); +} + +void RecordingCanvasBackend::polygon(const Vec2F* points, int count, const RGBA& color) { + recordPoints(CanvasCommand::Kind::Polygon, points, count, color, 0); +} + +void RecordingCanvasBackend::polygon(const Vec2F* points, int count, const LinearGradient& gradient) { + recordPoints(CanvasCommand::Kind::Polygon, points, count, gradient.fromColor, 0); + mCommands.back().endColor = gradient.toColor; +} + +void RecordingCanvasBackend::ringSegment( + Vec2F center, halnf, halnf outerRadius, halnf, halnf, const LinearGradient& gradient +) { + CanvasCommand& command = + record(CanvasCommand::Kind::RingSegment, squareAround(center, outerRadius), gradient.fromColor); + command.endColor = gradient.toColor; +} + +void RecordingCanvasBackend::radialGlow( + Vec2F center, halnf, halnf outerRadius, const RGBA& innerColor, const RGBA& outerColor +) { + record(CanvasCommand::Kind::RadialGlow, squareAround(center, outerRadius), innerColor).endColor = outerColor; +} + +void RecordingCanvasBackend::text(const std::string& text, const RectF& area, const TextStyle& style) { + CanvasCommand& command = record(CanvasCommand::Kind::Text, area, style.color); + command.text = text; + command.face = style.face; + command.fontSize = style.size; + command.align = style.align; +} + +void RecordingCanvasBackend::rotatedText(const std::string& text, Vec2F center, halnf size, halnf, const RGBA& color) { + const halnf width = textWidth(text, size, FontFace::Regular); + CanvasCommand& command = + record(CanvasCommand::Kind::RotatedText, { center - Vec2F(width, size) / 2, Vec2F(width, size) }, color); + command.text = text; + command.fontSize = size; +} + +halnf RecordingCanvasBackend::textWidth(const std::string& text, halnf size, FontFace) const { + mTextMeasurements++; + return (halnf) text.size() * size * kCharacterWidthFactor; +} + +std::vector RecordingCanvasBackend::prefixWidths(const std::string& text, halnf size, FontFace) const { + mTextMeasurements++; + std::vector widths(text.size() + 1); + for (size_t index = 0; index <= text.size(); index++) { + widths[index] = (halnf) index * size * kCharacterWidthFactor; + } + return widths; +} + +ImageHandle RecordingCanvasBackend::addImage(Vec2F size) { + const int id = (int) mImages.size() + 1; + mImages.push_back({ id, size, true }); + return makeImageHandle(id); +} + +ImageHandle RecordingCanvasBackend::createImageFromRenderTarget(uint32_t) { return addImage({ 1, 1 }); } + +ImageHandle RecordingCanvasBackend::createImageFromFile(const char* path) { + for (const ImageFile& file : mImageFiles) { + if (file.path == path) { + return addImage(file.size); + } + } + return {}; +} + +ImageHandle RecordingCanvasBackend::createImageFromMemoryRGBA(int width, int height, const unsigned char*) { + if (width <= 0 || height <= 0) { + return {}; + } + return addImage({ (halnf) width, (halnf) height }); +} + +Vec2F RecordingCanvasBackend::imageSize(ImageHandle image) { + const size_t index = (size_t) image.id() - 1; + if (index >= mImages.size() || !mImages[index].live) { + return { 0, 0 }; + } + return mImages[index].size; +} + +void RecordingCanvasBackend::deleteImage(ImageHandle image) { + const size_t index = (size_t) image.id() - 1; + if (index < mImages.size()) { + mImages[index].live = false; + } +} + +void RecordingCanvasBackend::drawImage(const RectF& rect, ImageHandle image, halnf, halnf, halnf rounding) { + CanvasCommand& command = record(CanvasCommand::Kind::Image, rect, RGBA(1, 1, 1, 1)); + command.image = image; + command.round = rounding; +} + +void RecordingCanvasBackend::addImageFile(const std::string& path, Vec2F size) { + mImageFiles.push_back({ path, size }); +} + +const CanvasCommand* RecordingCanvasBackend::findText(const std::string& text) const { + for (const CanvasCommand& command : mCommands) { + const bool isText = command.kind == CanvasCommand::Kind::Text || command.kind == CanvasCommand::Kind::RotatedText; + if (isText && command.text == text) { + return &command; + } + } + return nullptr; +} + +size_t RecordingCanvasBackend::liveImageCount() const { + return (size_t) std::count_if(mImages.begin(), mImages.end(), [](const Image& image) { return image.live; }); +} diff --git a/GraphicApplication/src/canvas/backends/recording/public/RecordingCanvasBackend.hpp b/GraphicApplication/src/canvas/backends/recording/public/RecordingCanvasBackend.hpp new file mode 100644 index 00000000..5096e027 --- /dev/null +++ b/GraphicApplication/src/canvas/backends/recording/public/RecordingCanvasBackend.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include "CanvasBackend.hpp" + +#include +#include +#include + +namespace tp { + + // One drawing command as a recording canvas saw it, in surface coordinates. + struct CanvasCommand { + enum class Kind { + Rect, + Gradient, + Outline, + Circle, + CircleOutline, + Line, + Polyline, + Polygon, + RingSegment, + RadialGlow, + Text, + RotatedText, + Image, + Path + }; + + Kind kind = Kind::Rect; + RectF rect; + RGBA color; + RGBA endColor; + halnf round = 0; + halnf width = 0; + std::string text; + FontFace face = FontFace::Regular; + halnf fontSize = 0; + TextAlign align; + std::vector points; + ImageHandle image; + RectF clip; + bool clipped = false; + CanvasBackend::CompositeOperation composite = CanvasBackend::CompositeOperation::SourceOver; + }; + + // Records drawing instead of rendering it, and measures text with a fixed advance per character, so layout and + // painting can be tested without a GPU. + class RecordingCanvasBackend final : public CanvasBackend { + public: + static constexpr halnf kCharacterWidthFactor = 0.5f; + + void setClip(std::optional clip) override; + void setCompositeOperation(CompositeOperation operation) override { mComposite = operation; } + + void beginPath() override; + void addPathRect(const RectF& rect, halnf round) override; + void setPathWinding(PathWinding) override {} + void fillPath(const RGBA& color) override; + void fillPath(const BoxGradient& gradient) override; + + void rect(const RectF& rect, const RGBA& color, halnf round) override; + void gradientRect(const RectF& rect, const RGBA& from, const RGBA& to, Axis axis, halnf round) override; + void outline(const RectF& rect, const RGBA& color, halnf round, halnf width) override; + void circle(Vec2F center, halnf radius, const RGBA& color) override; + void circleOutline(Vec2F center, halnf radius, const RGBA& color, halnf width) override; + void line(Vec2F start, Vec2F end, const RGBA& color, halnf thickness) override; + void polyline(const Vec2F* points, int count, const RGBA& color, halnf width) override; + void polygon(const Vec2F* points, int count, const RGBA& color) override; + void polygon(const Vec2F* points, int count, const LinearGradient& gradient) override; + void ringSegment( + Vec2F center, halnf innerRadius, halnf outerRadius, halnf fromAngle, halnf toAngle, const LinearGradient& gradient + ) override; + void radialGlow( + Vec2F center, halnf innerRadius, halnf outerRadius, const RGBA& innerColor, const RGBA& outerColor + ) override; + + void text(const std::string& text, const RectF& area, const TextStyle& style) override; + void rotatedText(const std::string& text, Vec2F center, halnf size, halnf angle, const RGBA& color) override; + + [[nodiscard]] halnf textWidth(const std::string& text, halnf size, FontFace face) const override; + [[nodiscard]] std::vector prefixWidths(const std::string& text, halnf size, FontFace face) const override; + + [[nodiscard]] ImageHandle createImageFromRenderTarget(uint32_t renderTargetId) override; + void updateImageFromRenderTarget(ImageHandle, uint32_t) override {} + [[nodiscard]] ImageHandle createImageFromFile(const char* path) override; + [[nodiscard]] ImageHandle createImageFromMemoryRGBA(int width, int height, const unsigned char* rgba) override; + void updateImageRGBA(ImageHandle, const unsigned char*) override {} + [[nodiscard]] Vec2F imageSize(ImageHandle image) override; + void deleteImage(ImageHandle image) override; + void drawImage(const RectF& rect, ImageHandle image, halnf angle, halnf alpha, halnf rounding) override; + + // Gives files at this path a size so image loading can be tested; other paths fail to load. + void addImageFile(const std::string& path, Vec2F size); + + [[nodiscard]] const std::vector& commands() const { return mCommands; } + [[nodiscard]] const CanvasCommand* findText(const std::string& text) const; + [[nodiscard]] bool hasText(const std::string& text) const { return findText(text) != nullptr; } + [[nodiscard]] size_t textMeasurements() const { return mTextMeasurements; } + [[nodiscard]] size_t liveImageCount() const; + void clear() { mCommands.clear(); } + + private: + struct Image { + int id = 0; + Vec2F size = { 0, 0 }; + bool live = true; + }; + + struct ImageFile { + std::string path; + Vec2F size; + }; + + CanvasCommand& record(CanvasCommand::Kind kind, const RectF& rect, const RGBA& color); + void recordPoints(CanvasCommand::Kind kind, const Vec2F* points, int count, const RGBA& color, halnf width); + ImageHandle addImage(Vec2F size); + + std::vector mCommands; + std::vector mImages; + std::vector mImageFiles; + std::vector mPathRects; + std::optional mClip; + CompositeOperation mComposite = CompositeOperation::SourceOver; + mutable size_t mTextMeasurements = 0; + }; +} diff --git a/GraphicApplication/src/canvas/private/Canvas.cpp b/GraphicApplication/src/canvas/private/Canvas.cpp index 88008b93..71226e9a 100644 --- a/GraphicApplication/src/canvas/private/Canvas.cpp +++ b/GraphicApplication/src/canvas/private/Canvas.cpp @@ -1,110 +1,22 @@ -#include "CanvasContext.hpp" - -#include "GpuError.hpp" -#include "Profiler.hpp" - -#include - -#include +#include "Canvas.hpp" using namespace tp; -namespace { - constexpr halnf kItalicSkew = -0.21f; +Canvas::Canvas(std::unique_ptr backend) : + mBackend(std::move(backend)) {} - bool isItalic(FontFace face) { return face == FontFace::Italic || face == FontFace::BoldItalic; } - - void addRect(NVGcontext* vg, const RectF& rect, halnf round) { - if (round == 0) { - nvgRect(vg, rect.x, rect.y, rect.z, rect.w); - } else { - nvgRoundedRect(vg, rect.x, rect.y, rect.z, rect.w, round); - } - } - - void stroke(NVGcontext* vg, const RGBA& color, halnf width) { - nvgStrokeWidth(vg, width); - nvgStrokeColor(vg, toNVGColor(color)); - nvgStroke(vg); - } - - void fill(NVGcontext* vg, const RGBA& color) { - nvgFillColor(vg, toNVGColor(color)); - nvgFill(vg); - } - - NVGpaint linearPaint(NVGcontext* vg, const Canvas::LinearGradient& gradient, Vec2F origin) { - const Vec2F from = gradient.from + origin; - const Vec2F to = gradient.to + origin; - return nvgLinearGradient( - vg, from.x, from.y, to.x, to.y, toNVGColor(gradient.fromColor), toNVGColor(gradient.toColor) - ); - } - - int nvgCompositeOperationOf(Canvas::CompositeOperation operation) { - switch (operation) { - case Canvas::CompositeOperation::SourceOver: return NVG_SOURCE_OVER; - case Canvas::CompositeOperation::DestinationOut: return NVG_DESTINATION_OUT; - case Canvas::CompositeOperation::DestinationOver: return NVG_DESTINATION_OVER; - } - return NVG_SOURCE_OVER; - } - - int nvgWindingOf(Canvas::PathWinding winding) { return winding == Canvas::PathWinding::Hole ? NVG_HOLE : NVG_SOLID; } -} - -Canvas::Canvas(WindowSurface& surface, Profiler& profiler, const std::filesystem::path& resourceDirectory) : - mContext(std::make_unique()) { - mContext->surface = &surface; - mContext->profiler = &profiler; - mContext->flushSection = profiler.section("frame.ui_flush"); - - const GpuDevice& gpu = surface.gpu(); - VKNVGCreateInfo createInfo{}; - createInfo.gpu = gpu.physicalDevice(); - createInfo.device = gpu.device(); - createInfo.renderpass = surface.renderPass(); - createInfo.cmdBuffer = mContext->frameCommandBuffers; - createInfo.frameCount = WindowSurface::kFramesInFlight; - createInfo.currentFrame = &mContext->frameSlot; - createInfo.commandPool = gpu.commandPool(); - createInfo.graphicsQueueFamilyIndex = gpu.graphicsQueueFamily(); - - mContext->vg = nvgCreateVk(&createInfo, NVG_ANTIALIAS | NVG_STENCIL_STROKES, gpu.graphicsQueue()); - if (!mContext->vg) { - throw GpuError("creating the NanoVG context", VK_ERROR_INITIALIZATION_FAILED); - } - mContext->fonts = loadCanvasFonts(mContext->vg, resourceDirectory); -} - -Canvas::~Canvas() { - mContext->surface->gpu().waitIdle(); - nvgDeleteVk(mContext->vg); -} - -void Canvas::drawBegin(const GpuFrame& frame, const WindowFrame& windowFrame) { - mContext->frameCommandBuffers[frame.slot] = frame.commandBuffer; - mContext->frameSlot = frame.slot; - - const Vec2F surfaceSize = windowFrame.surfaceSize(); - const halnf devicePixelRatio = surfaceSize.x > 0 ? (halnf) frame.extent.width / surfaceSize.x : 1.0f; - nvgBeginFrame(mContext->vg, surfaceSize.x, surfaceSize.y, devicePixelRatio); - nvgTranslate(mContext->vg, windowFrame.margin(), windowFrame.margin()); +Canvas::~Canvas() = default; +void Canvas::beginFrame() { mOrigin = { 0, 0 }; mClips.clear(); mSavedFrames.clear(); + mComposite = CompositeOperation::SourceOver; + mBackend->setClip(std::nullopt); + mBackend->setCompositeOperation(mComposite); } -void Canvas::drawEnd() { - const ProfileScope scope(*mContext->profiler, mContext->flushSection); - nvgEndFrame(mContext->vg); -} - -void Canvas::save() { - nvgSave(mContext->vg); - mSavedFrames.append({ mOrigin, mClips.size() }); -} +void Canvas::save() { mSavedFrames.append({ mOrigin, mClips.size(), mComposite }); } void Canvas::restore() { DEBUG_ASSERT(mSavedFrames.size()); @@ -115,203 +27,23 @@ void Canvas::restore() { mClips.pop(); } mOrigin = saved.origin; - nvgRestore(mContext->vg); applyClip(); -} - -void Canvas::setCompositeOperation(CompositeOperation operation) { - nvgGlobalCompositeOperation(mContext->vg, nvgCompositeOperationOf(operation)); -} - -void Canvas::beginPath() { nvgBeginPath(mContext->vg); } - -void Canvas::addPathRect(RectF rect, halnf round) { - rect.pos += mOrigin; - addRect(mContext->vg, rect, round); -} - -void Canvas::setPathWinding(PathWinding winding) { nvgPathWinding(mContext->vg, nvgWindingOf(winding)); } - -void Canvas::fillPath(const RGBA& color) { fill(mContext->vg, color); } - -void Canvas::fillPath(const BoxGradient& gradient) { - const RectF box = { gradient.box.pos + mOrigin, gradient.box.size }; - const NVGpaint paint = nvgBoxGradient( - mContext->vg, - box.x, - box.y, - box.z, - box.w, - gradient.radius, - gradient.feather, - toNVGColor(gradient.innerColor), - toNVGColor(gradient.outerColor) - ); - nvgFillPaint(mContext->vg, paint); - nvgFill(mContext->vg); -} - -void Canvas::rect(RectF rect, const RGBA& color, halnf round) { - rect.pos += mOrigin; - nvgBeginPath(mContext->vg); - addRect(mContext->vg, rect, round); - fill(mContext->vg, color); -} - -void Canvas::gradientRect(RectF rect, const RGBA& from, const RGBA& to, bool horizontal, halnf round) { - rect.pos += mOrigin; - NVGcontext* vg = mContext->vg; - const halnf endX = horizontal ? rect.x + rect.z : rect.x; - const halnf endY = horizontal ? rect.y : rect.y + rect.w; - const NVGpaint paint = nvgLinearGradient(vg, rect.x, rect.y, endX, endY, toNVGColor(from), toNVGColor(to)); - nvgBeginPath(vg); - addRect(vg, rect, round); - nvgFillPaint(vg, paint); - nvgFill(vg); -} - -void Canvas::debugCross(RectF rect, const RGBA& color) { - rect.pos += mOrigin; - NVGcontext* vg = mContext->vg; - nvgBeginPath(vg); - nvgMoveTo(vg, rect.p1().x, rect.p1().y); - nvgLineTo(vg, rect.p3().x, rect.p3().y); - nvgMoveTo(vg, rect.p2().x, rect.p2().y); - nvgLineTo(vg, rect.p4().x, rect.p4().y); - stroke(vg, color, 2); -} - -void Canvas::frame(RectF rect, const RGBA& color) { - rect.pos += mOrigin; - NVGcontext* vg = mContext->vg; - nvgBeginPath(vg); - nvgRect(vg, rect.x, rect.y, rect.z, rect.w); - stroke(vg, color, 2); -} - -void Canvas::outline(RectF rect, const RGBA& color, halnf round, halnf width) { - rect.pos += mOrigin; - const halnf inset = width / 2; - nvgBeginPath(mContext->vg); - nvgRoundedRect( - mContext->vg, rect.x + inset, rect.y + inset, rect.z - width, rect.w - width, round > inset ? round - inset : 0 - ); - stroke(mContext->vg, color, width); -} - -void Canvas::circleOutline(Vec2F center, halnf radius, const RGBA& color, halnf width) { - center += mOrigin; - nvgBeginPath(mContext->vg); - nvgCircle(mContext->vg, center.x, center.y, radius - width / 2); - stroke(mContext->vg, color, width); -} - -void Canvas::polyline(const Vec2F* points, int count, const RGBA& color, halnf width) { - if (count < 2) { - return; + if (mComposite != saved.composite) { + mComposite = saved.composite; + mBackend->setCompositeOperation(mComposite); } - - nvgBeginPath(mContext->vg); - nvgMoveTo(mContext->vg, points[0].x + mOrigin.x, points[0].y + mOrigin.y); - for (int index = 1; index < count; index++) { - nvgLineTo(mContext->vg, points[index].x + mOrigin.x, points[index].y + mOrigin.y); - } - nvgLineCap(mContext->vg, NVG_ROUND); - nvgLineJoin(mContext->vg, NVG_ROUND); - stroke(mContext->vg, color, width); -} - -void Canvas::polygon(const Vec2F* points, int count, const RGBA& color) { - if (count < 3) { - return; - } - - nvgBeginPath(mContext->vg); - nvgMoveTo(mContext->vg, points[0].x + mOrigin.x, points[0].y + mOrigin.y); - for (int index = 1; index < count; index++) { - nvgLineTo(mContext->vg, points[index].x + mOrigin.x, points[index].y + mOrigin.y); - } - nvgClosePath(mContext->vg); - fill(mContext->vg, color); -} - -void Canvas::polygon(const Vec2F* points, int count, const LinearGradient& gradient) { - if (count < 3) { - return; - } - - nvgBeginPath(mContext->vg); - nvgMoveTo(mContext->vg, points[0].x + mOrigin.x, points[0].y + mOrigin.y); - for (int index = 1; index < count; index++) { - nvgLineTo(mContext->vg, points[index].x + mOrigin.x, points[index].y + mOrigin.y); - } - nvgClosePath(mContext->vg); - nvgFillPaint(mContext->vg, linearPaint(mContext->vg, gradient, mOrigin)); - nvgFill(mContext->vg); -} - -void Canvas::ringSegment( - Vec2F center, halnf innerRadius, halnf outerRadius, halnf fromAngle, halnf toAngle, const LinearGradient& gradient -) { - center += mOrigin; - - nvgBeginPath(mContext->vg); - nvgArc(mContext->vg, center.x, center.y, innerRadius, fromAngle, toAngle, NVG_CW); - nvgArc(mContext->vg, center.x, center.y, outerRadius, toAngle, fromAngle, NVG_CCW); - nvgClosePath(mContext->vg); - nvgFillPaint(mContext->vg, linearPaint(mContext->vg, gradient, mOrigin)); - nvgFill(mContext->vg); -} - -void Canvas::radialGlow( - Vec2F center, halnf innerRadius, halnf outerRadius, const RGBA& innerColor, const RGBA& outerColor -) { - center += mOrigin; - - const NVGpaint paint = nvgRadialGradient( - mContext->vg, center.x, center.y, innerRadius, outerRadius, toNVGColor(innerColor), toNVGColor(outerColor) - ); - - nvgBeginPath(mContext->vg); - nvgCircle(mContext->vg, center.x, center.y, outerRadius); - nvgFillPaint(mContext->vg, paint); - nvgFill(mContext->vg); -} - -void Canvas::line(Vec2F start, Vec2F end, const RGBA& color, halnf thickness) { - start += mOrigin; - end += mOrigin; - nvgBeginPath(mContext->vg); - nvgMoveTo(mContext->vg, start.x, start.y); - nvgLineTo(mContext->vg, end.x, end.y); - stroke(mContext->vg, color, thickness); -} - -void Canvas::circle(Vec2F center, halnf radius, const RGBA& color) { - center += mOrigin; - nvgBeginPath(mContext->vg); - nvgCircle(mContext->vg, center.x, center.y, radius); - fill(mContext->vg, color); } void Canvas::clipTo(const RectF& rect) { - const RectF placed = { rect.pos + mOrigin, rect.size }; - RectF clip = placed; + RectF clip = placed(rect); if (mClips.size()) { - mClips.last().calcIntersection(placed, clip); + mClips.last().calcIntersection(placed(rect), clip); } mClips.append(clip); applyClip(); } -void Canvas::applyClip() { - if (!mClips.size()) { - nvgResetScissor(mContext->vg); - return; - } - const RectF& clip = mClips.last(); - nvgScissor(mContext->vg, clip.x, clip.y, clip.z, clip.w); -} +void Canvas::applyClip() { mBackend->setClip(mClips.size() ? std::optional(mClips.last()) : std::nullopt); } bool Canvas::isClippedAway() const { return mClips.size() && mClips.last().size.length2() <= EPSILON; } @@ -319,115 +51,148 @@ bool Canvas::isOutsideClip(const RectF& rect) const { if (!mClips.size()) { return false; } - const RectF placed = { rect.pos + mOrigin, rect.size }; + const RectF area = placed(rect); const RectF& clip = mClips.last(); - return placed.x >= clip.x + clip.z || clip.x >= placed.x + placed.z || placed.y >= clip.y + clip.w || - clip.y >= placed.y + placed.w; + return area.x >= clip.x + clip.z || clip.x >= area.x + area.z || area.y >= clip.y + clip.w || + clip.y >= area.y + area.w; } -void Canvas::text(const char* string, const RectF& area, const TextStyle& style) { +std::vector Canvas::placed(const std::vector& points) const { + std::vector result; + result.reserve(points.size()); + for (const Vec2F& point : points) { + result.push_back(point + mOrigin); + } + return result; +} + +Canvas::LinearGradient Canvas::placed(const LinearGradient& gradient) const { + return { gradient.from + mOrigin, gradient.to + mOrigin, gradient.fromColor, gradient.toColor }; +} + +void Canvas::setCompositeOperation(CompositeOperation operation) { + mComposite = operation; + mBackend->setCompositeOperation(operation); +} + +void Canvas::beginPath() { mBackend->beginPath(); } + +void Canvas::addPathRect(RectF rect, halnf round) { mBackend->addPathRect(placed(rect), round); } + +void Canvas::setPathWinding(PathWinding winding) { mBackend->setPathWinding(winding); } + +void Canvas::fillPath(const RGBA& color) { mBackend->fillPath(color); } + +void Canvas::fillPath(const BoxGradient& gradient) { + BoxGradient placedGradient = gradient; + placedGradient.box = placed(gradient.box); + mBackend->fillPath(placedGradient); +} + +void Canvas::rect(RectF rect, const RGBA& color, halnf round) { mBackend->rect(placed(rect), color, round); } + +void Canvas::gradientRect(RectF rect, const RGBA& from, const RGBA& to, Axis axis, halnf round) { + mBackend->gradientRect(placed(rect), from, to, axis, round); +} + +void Canvas::outline(RectF rect, const RGBA& color, halnf round, halnf width) { + mBackend->outline(placed(rect), color, round, width); +} + +void Canvas::circle(Vec2F center, halnf radius, const RGBA& color) { + mBackend->circle(center + mOrigin, radius, color); +} + +void Canvas::circleOutline(Vec2F center, halnf radius, const RGBA& color, halnf width) { + mBackend->circleOutline(center + mOrigin, radius, color, width); +} + +void Canvas::line(Vec2F start, Vec2F end, const RGBA& color, halnf thickness) { + mBackend->line(start + mOrigin, end + mOrigin, color, thickness); +} + +void Canvas::polyline(const std::vector& points, const RGBA& color, halnf width) { + const std::vector placedPoints = placed(points); + mBackend->polyline(placedPoints.data(), (int) placedPoints.size(), color, width); +} + +void Canvas::polygon(const std::vector& points, const RGBA& color) { + const std::vector placedPoints = placed(points); + mBackend->polygon(placedPoints.data(), (int) placedPoints.size(), color); +} + +void Canvas::polygon(const std::vector& points, const LinearGradient& gradient) { + const std::vector placedPoints = placed(points); + mBackend->polygon(placedPoints.data(), (int) placedPoints.size(), placed(gradient)); +} + +void Canvas::ringSegment( + Vec2F center, halnf innerRadius, halnf outerRadius, halnf fromAngle, halnf toAngle, const LinearGradient& gradient +) { + mBackend->ringSegment(center + mOrigin, innerRadius, outerRadius, fromAngle, toAngle, placed(gradient)); +} + +void Canvas::radialGlow( + Vec2F center, halnf innerRadius, halnf outerRadius, const RGBA& innerColor, const RGBA& outerColor +) { + mBackend->radialGlow(center + mOrigin, innerRadius, outerRadius, innerColor, outerColor); +} + +void Canvas::text(const std::string& string, const RectF& area, const TextStyle& style) { const halnf padding = style.padding; const RectF padded = { area.x + padding, area.y + padding, area.z - padding * 2, area.w - padding * 2 }; if (isOutsideClip(padded)) { return; } - const SavedState saved(*this); - clipTo(padded); - const RectF rect = { padded.pos + mOrigin, padded.size }; - - NVGcontext* vg = mContext->vg; - nvgFontSize(vg, style.size); - nvgFontFaceId(vg, mContext->fonts.idFor(style.face)); - nvgFillColor(vg, toNVGColor(style.color)); - - halnf anchorX = rect.x; - halnf anchorY = rect.y; - int alignment = 0; - switch (style.align.horizontal) { - case HorizontalAlign::Left: alignment |= NVG_ALIGN_LEFT; break; - case HorizontalAlign::Center: - alignment |= NVG_ALIGN_CENTER; - anchorX += rect.z * 0.5f; - break; - case HorizontalAlign::Right: - alignment |= NVG_ALIGN_RIGHT; - anchorX += rect.z; - break; - } - switch (style.align.vertical) { - case VerticalAlign::Top: alignment |= NVG_ALIGN_TOP; break; - case VerticalAlign::Center: - alignment |= NVG_ALIGN_MIDDLE; - anchorY += rect.w * 0.5f; - break; - case VerticalAlign::Bottom: - alignment |= NVG_ALIGN_BOTTOM; - anchorY += rect.w; - break; - } - nvgTextAlign(vg, alignment); - - if (!isItalic(style.face)) { - nvgText(vg, anchorX, anchorY, string, nullptr); - return; - } - nvgTranslate(vg, anchorX, anchorY); - nvgSkewX(vg, kItalicSkew); - nvgText(vg, 0, 0, string, nullptr); + const Clip clip(*this, padded); + mBackend->text(string, placed(padded), style); } -void Canvas::rotatedText(const char* string, Vec2F center, halnf size, halnf angle, const RGBA& color) { - center += mOrigin; - - nvgSave(mContext->vg); - nvgTranslate(mContext->vg, center.x, center.y); - nvgRotate(mContext->vg, angle); - nvgFontSize(mContext->vg, size); - nvgFontFaceId(mContext->vg, mContext->fonts.regular); - nvgTextAlign(mContext->vg, NVG_ALIGN_CENTER | NVG_ALIGN_MIDDLE); - nvgFillColor(mContext->vg, toNVGColor(color)); - nvgText(mContext->vg, 0, 0, string, nullptr); - nvgRestore(mContext->vg); +void Canvas::rotatedText(const std::string& string, Vec2F center, halnf size, halnf angle, const RGBA& color) { + mBackend->rotatedText(string, center + mOrigin, size, angle, color); } -halnf Canvas::textWidth(const char* string, halnf size, FontFace face) { - nvgFontSize(mContext->vg, size); - nvgFontFaceId(mContext->vg, mContext->fonts.idFor(face)); - return nvgTextBounds(mContext->vg, 0, 0, string, nullptr, nullptr); +halnf Canvas::textWidth(const std::string& string, halnf size, FontFace face) const { + return mBackend->textWidth(string, size, face); } -void Canvas::prefixWidths(const char* string, size_t length, halnf size, FontFace face, halnf* widths) { - nvgFontSize(mContext->vg, size); - nvgFontFaceId(mContext->vg, mContext->fonts.idFor(face)); - nvgTextAlign(mContext->vg, NVG_ALIGN_LEFT | NVG_ALIGN_TOP); +std::vector Canvas::prefixWidths(const std::string& string, halnf size, FontFace face) const { + return mBackend->prefixWidths(string, size, face); +} - std::vector glyphs(length); - const int count = nvgTextGlyphPositions(mContext->vg, 0, 0, string, string + length, glyphs.data(), (int) length); +ImageHandle Canvas::createImageFromRenderTarget(uint32_t renderTargetId) { + return mBackend->createImageFromRenderTarget(renderTargetId); +} - size_t byte = 0; - for (int index = 0; index < count; index++) { - const size_t glyphStart = (size_t) (glyphs[(size_t) index].str - string); - const halnf pen = glyphs[(size_t) index].x; - for (; byte <= glyphStart && byte < length; byte++) { - widths[byte] = pen; - } +void Canvas::updateImageFromRenderTarget(ImageHandle image, uint32_t renderTargetId) { + if (image.isValid()) { + mBackend->updateImageFromRenderTarget(image, renderTargetId); } - const halnf total = nvgTextBounds(mContext->vg, 0, 0, string, string + length, nullptr); - for (; byte < length; byte++) { - widths[byte] = total; +} + +ImageHandle Canvas::createImageFromFile(const char* path) { return mBackend->createImageFromFile(path); } + +ImageHandle Canvas::createImageFromMemoryRGBA(int width, int height, const unsigned char* rgba) { + return mBackend->createImageFromMemoryRGBA(width, height, rgba); +} + +void Canvas::updateImageRGBA(ImageHandle image, const unsigned char* rgba) { + if (image.isValid()) { + mBackend->updateImageRGBA(image, rgba); + } +} + +Vec2F Canvas::imageSize(ImageHandle image) { return image.isValid() ? mBackend->imageSize(image) : Vec2F(0, 0); } + +void Canvas::deleteImage(ImageHandle image) { + if (image.isValid()) { + mBackend->deleteImage(image); } - widths[length] = total; } void Canvas::drawImage(RectF rect, ImageHandle image, halnf angle, halnf alpha, halnf rounding) { - if (!image.isValid()) { - return; + if (image.isValid()) { + mBackend->drawImage(placed(rect), image, angle, alpha, rounding); } - rect.pos += mOrigin; - const NVGpaint paint = nvgImagePattern(mContext->vg, rect.x, rect.y, rect.z, rect.w, angle, image.mId, alpha); - nvgBeginPath(mContext->vg); - nvgRoundedRect(mContext->vg, rect.x, rect.y, rect.z, rect.w, rounding); - nvgFillPaint(mContext->vg, paint); - nvgFill(mContext->vg); } diff --git a/GraphicApplication/src/canvas/private/CanvasContext.hpp b/GraphicApplication/src/canvas/private/CanvasContext.hpp deleted file mode 100644 index 5dc527b9..00000000 --- a/GraphicApplication/src/canvas/private/CanvasContext.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "Canvas.hpp" -#include "CanvasFontLoader.hpp" - -#include - -namespace tp { - - class Canvas::Context { - public: - NVGcontext* vg{}; - WindowSurface* surface{}; - Profiler* profiler{}; - uint32_t flushSection = 0; - CanvasFontIds fonts; - - // NanoVG reads the recording command buffer and frame slot through these pointers on every flush. - VkCommandBuffer frameCommandBuffers[WindowSurface::kFramesInFlight] = {}; - uint32_t frameSlot = 0; - }; - - inline NVGcolor toNVGColor(const RGBA& color) { return { color.r, color.g, color.b, color.a }; } -} diff --git a/GraphicApplication/src/canvas/private/CanvasImages.cpp b/GraphicApplication/src/canvas/private/CanvasImages.cpp deleted file mode 100644 index f5e88bc6..00000000 --- a/GraphicApplication/src/canvas/private/CanvasImages.cpp +++ /dev/null @@ -1,70 +0,0 @@ -#include "CanvasContext.hpp" - -#include "RenderTargetRegistry.hpp" - -#include - -using namespace tp; - -ImageHandle Canvas::createImageFromRenderTarget(uint32_t renderTargetId) { - RenderTargetEntry entry; - if (!mContext->surface->gpu().renderTargets().resolve(renderTargetId, entry)) { - return {}; - } - const int image = nvgCreateImageFromHandleVk( - mContext->vg, - entry.image, - entry.view, - entry.sampler, - VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, - entry.width, - entry.height, - 0 - ); - return ImageHandle(image); -} - -void Canvas::updateImageFromRenderTarget(ImageHandle image, uint32_t renderTargetId) { - RenderTargetEntry entry; - if (!image.isValid() || !mContext->surface->gpu().renderTargets().resolve(renderTargetId, entry)) { - return; - } - nvgUpdateImageFromHandleVk( - mContext->vg, - image.mId, - entry.image, - entry.view, - entry.sampler, - VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, - entry.width, - entry.height - ); -} - -ImageHandle Canvas::createImageFromFile(const char* path) { return ImageHandle(nvgCreateImage(mContext->vg, path, 0)); } - -ImageHandle Canvas::createImageFromMemoryRGBA(int width, int height, const unsigned char* rgba) { - return ImageHandle(nvgCreateImageRGBA(mContext->vg, width, height, 0, rgba)); -} - -void Canvas::updateImageRGBA(ImageHandle image, const unsigned char* rgba) { - if (image.isValid()) { - nvgUpdateImage(mContext->vg, image.mId, rgba); - } -} - -Vec2F Canvas::imageSize(ImageHandle image) { - if (!image.isValid()) { - return { 0, 0 }; - } - int width = 0; - int height = 0; - nvgImageSize(mContext->vg, image.mId, &width, &height); - return { (halnf) width, (halnf) height }; -} - -void Canvas::deleteImage(ImageHandle image) { - if (image.isValid()) { - nvgDeleteImage(mContext->vg, image.mId); - } -} diff --git a/GraphicApplication/src/canvas/public/Canvas.hpp b/GraphicApplication/src/canvas/public/Canvas.hpp index 00110671..7e746244 100644 --- a/GraphicApplication/src/canvas/public/Canvas.hpp +++ b/GraphicApplication/src/canvas/public/Canvas.hpp @@ -1,9 +1,6 @@ #pragma once -#include "Color.hpp" -#include "Rect.hpp" -#include "WindowFrame.hpp" -#include "WindowSurface.hpp" +#include "CanvasBackend.hpp" #include "Buffer.hpp" @@ -13,77 +10,39 @@ namespace tp { class Profiler; + class NanoVgCanvasBackend; + class WindowSurface; + class WindowFrame; + struct GpuFrame; - enum class HorizontalAlign { Left, Center, Right }; - - enum class VerticalAlign { Top, Center, Bottom }; - - struct TextAlign { - HorizontalAlign horizontal = HorizontalAlign::Left; - VerticalAlign vertical = VerticalAlign::Center; - - static constexpr TextAlign leftCenter() { return { HorizontalAlign::Left, VerticalAlign::Center }; } - static constexpr TextAlign centered() { return { HorizontalAlign::Center, VerticalAlign::Center }; } - }; - - enum class FontFace { Regular, Bold, Italic, BoldItalic, Monospace }; - - struct TextStyle { - halnf size = 16; - RGBA color = RGBA(1, 1, 1, 1); - TextAlign align; - halnf padding = 0; - FontFace face = FontFace::Regular; - }; - - class ImageHandle { + // 2D vector drawing over a backend: NanoVG on a window surface, or a recording backend for headless tests. + // Coordinates are relative to the current placement origin, which starts at the content origin; translations and + // clips are scoped. + class Canvas : public TextMetrics { public: - ImageHandle() = default; + using CompositeOperation = CanvasBackend::CompositeOperation; + using PathWinding = CanvasBackend::PathWinding; + using LinearGradient = CanvasBackend::LinearGradient; + using BoxGradient = CanvasBackend::BoxGradient; - [[nodiscard]] bool isValid() const { return mId > 0; } - [[nodiscard]] bool operator==(const ImageHandle& other) const = default; - - private: - friend class Canvas; - explicit ImageHandle(int id) : - mId(id) {} - - int mId = 0; - }; - - // 2D vector drawing for one window surface. Coordinates are relative to the current placement origin, which - // starts at the content origin; placements and clips are scoped. - class Canvas { - class Context; - - public: + // A canvas drawing through the given backend; beginFrame() starts a frame. + explicit Canvas(std::unique_ptr backend); + // A canvas drawing onto a window surface; defined with the NanoVG backend, which is the only place that may know + // the GPU frame. Frame boundaries are beginFrame(frame, windowFrame) and endFrame(). Canvas(WindowSurface& surface, Profiler& profiler, const std::filesystem::path& resourceDirectory); - ~Canvas(); + ~Canvas() override; Canvas(const Canvas&) = delete; Canvas& operator=(const Canvas&) = delete; - void drawBegin(const GpuFrame& frame, const WindowFrame& windowFrame); - void drawEnd(); + // Resets origin, clips and composite operation for a new frame. + void beginFrame(); + // Starts a frame of the window surface this canvas draws onto, then resets the canvas state. + void beginFrame(const GpuFrame& frame, const WindowFrame& windowFrame); + // Finishes the surface frame. + void endFrame(); - enum class CompositeOperation { SourceOver, DestinationOut, DestinationOver }; - - enum class PathWinding { Solid, Hole }; - - struct LinearGradient { - Vec2F from; - Vec2F to; - RGBA fromColor; - RGBA toColor; - }; - - struct BoxGradient { - RectF box; - halnf radius = 0; - halnf feather = 0; - RGBA innerColor; - RGBA outerColor; - }; + [[nodiscard]] CanvasBackend& backend() { return *mBackend; } // Restores origin, clipping and composite operation when the scope ends. class SavedState { @@ -111,6 +70,15 @@ namespace tp { } }; + // Intersects the clip with an area relative to the current origin until destroyed. + class Clip : public SavedState { + public: + Clip(Canvas& canvas, const RectF& area) : + SavedState(canvas) { + mCanvas.clipTo(area); + } + }; + // Places the origin at a point relative to the current origin and clips to an area there, until destroyed. class Placement : public SavedState { public: @@ -134,25 +102,25 @@ namespace tp { void fillPath(const BoxGradient& gradient); void rect(RectF rect, const RGBA& color, halnf round = 0); - void gradientRect(RectF rect, const RGBA& from, const RGBA& to, bool horizontal, halnf round = 0); - void frame(RectF rect, const RGBA& color); - void debugCross(RectF rect, const RGBA& color); - void outline(RectF rect, const RGBA& color, halnf round, halnf width); + void gradientRect(RectF rect, const RGBA& from, const RGBA& to, Axis axis, halnf round = 0); + void outline(RectF rect, const RGBA& color, halnf round = 0, halnf width = 1); void circle(Vec2F center, halnf radius, const RGBA& color); - void circleOutline(Vec2F center, halnf radius, const RGBA& color, halnf width); - void line(Vec2F start, Vec2F end, const RGBA& color, halnf thickness); - void polyline(const Vec2F* points, int count, const RGBA& color, halnf width); - void polygon(const Vec2F* points, int count, const RGBA& color); - void polygon(const Vec2F* points, int count, const LinearGradient& gradient); + void circleOutline(Vec2F center, halnf radius, const RGBA& color, halnf width = 1); + void line(Vec2F start, Vec2F end, const RGBA& color, halnf thickness = 1); + void polyline(const std::vector& points, const RGBA& color, halnf width = 1); + void polygon(const std::vector& points, const RGBA& color); + void polygon(const std::vector& points, const LinearGradient& gradient); void ringSegment( Vec2F center, halnf innerRadius, halnf outerRadius, halnf fromAngle, halnf toAngle, const LinearGradient& gradient ); void radialGlow(Vec2F center, halnf innerRadius, halnf outerRadius, const RGBA& innerColor, const RGBA& outerColor); - void text(const char* text, const RectF& area, const TextStyle& style); - void rotatedText(const char* text, Vec2F center, halnf size, halnf angle, const RGBA& color); - [[nodiscard]] halnf textWidth(const char* text, halnf size, FontFace face = FontFace::Regular); - void prefixWidths(const char* text, size_t length, halnf size, FontFace face, halnf* widths); + void text(const std::string& text, const RectF& area, const TextStyle& style); + void rotatedText(const std::string& text, Vec2F center, halnf size, halnf angle, const RGBA& color); + + [[nodiscard]] halnf + textWidth(const std::string& text, halnf size, FontFace face = FontFace::Regular) const override; + [[nodiscard]] std::vector prefixWidths(const std::string& text, halnf size, FontFace face) const override; [[nodiscard]] ImageHandle createImageFromRenderTarget(uint32_t renderTargetId); void updateImageFromRenderTarget(ImageHandle image, uint32_t renderTargetId); @@ -167,6 +135,7 @@ namespace tp { struct SavedFrame { Vec2F origin; size_t clipDepth = 0; + CompositeOperation composite = CompositeOperation::SourceOver; }; void save(); @@ -174,10 +143,15 @@ namespace tp { void translateOrigin(Vec2F offset) { mOrigin += offset; } void clipTo(const RectF& rect); void applyClip(); + [[nodiscard]] RectF placed(const RectF& rect) const { return { rect.pos + mOrigin, rect.size }; } + [[nodiscard]] std::vector placed(const std::vector& points) const; + [[nodiscard]] LinearGradient placed(const LinearGradient& gradient) const; - std::unique_ptr mContext; + std::unique_ptr mBackend; + NanoVgCanvasBackend* mSurfaceBackend = nullptr; Buffer mClips; Buffer mSavedFrames; Vec2F mOrigin = { 0, 0 }; + CompositeOperation mComposite = CompositeOperation::SourceOver; }; } diff --git a/GraphicApplication/tests/CanvasTests.cpp b/GraphicApplication/tests/CanvasTests.cpp new file mode 100644 index 00000000..387779fc --- /dev/null +++ b/GraphicApplication/tests/CanvasTests.cpp @@ -0,0 +1,201 @@ +#include "Canvas.hpp" +#include "RecordingCanvasBackend.hpp" + +#include "UnitTest++/UnitTest++.h" + +#include + +using namespace tp; + +namespace { + constexpr halnf kTolerance = 0.001f; + + struct RecordingScene { + RecordingCanvasBackend* backend = nullptr; + std::unique_ptr canvas; + + RecordingScene() { + auto recording = std::make_unique(); + backend = recording.get(); + canvas = std::make_unique(std::move(recording)); + canvas->beginFrame(); + } + + [[nodiscard]] const CanvasCommand& last() const { return backend->commands().back(); } + }; + + void checkRect(const RectF& expected, const RectF& actual) { + CHECK_CLOSE(expected.x, actual.x, kTolerance); + CHECK_CLOSE(expected.y, actual.y, kTolerance); + CHECK_CLOSE(expected.z, actual.z, kTolerance); + CHECK_CLOSE(expected.w, actual.w, kTolerance); + } +} + +SUITE(Canvas) { + TEST(DrawingIsPlacedAtTheTranslatedOrigin) { + RecordingScene scene; + { + const Canvas::Translation translation(*scene.canvas, { 10, 20 }); + scene.canvas->rect({ 1, 2, 3, 4 }, RGBA(1, 0, 0, 1)); + } + checkRect({ 11, 22, 3, 4 }, scene.last().rect); + + scene.canvas->rect({ 1, 2, 3, 4 }, RGBA(1, 0, 0, 1)); + checkRect({ 1, 2, 3, 4 }, scene.last().rect); + } + + TEST(ClipReachesTheBackendAndIntersectsNestedClips) { + RecordingScene scene; + { + const Canvas::Clip outer(*scene.canvas, { 0, 0, 100, 50 }); + const Canvas::Translation translation(*scene.canvas, { 80, 10 }); + const Canvas::Clip inner(*scene.canvas, { 0, 0, 40, 100 }); + scene.canvas->rect({ 0, 0, 10, 10 }, RGBA(1, 1, 1, 1)); + } + + CHECK(scene.last().clipped); + checkRect({ 80, 10, 20, 40 }, scene.last().clip); + } + + TEST(ClosingAClipScopeRestoresTheOuterClip) { + RecordingScene scene; + const Canvas::Clip outer(*scene.canvas, { 0, 0, 100, 100 }); + { + const Canvas::Clip inner(*scene.canvas, { 10, 10, 20, 20 }); + } + scene.canvas->rect({ 0, 0, 1, 1 }, RGBA(1, 1, 1, 1)); + + checkRect({ 0, 0, 100, 100 }, scene.last().clip); + } + + TEST(DrawingAfterAllScopesCloseIsUnclipped) { + RecordingScene scene; + { + const Canvas::Clip clip(*scene.canvas, { 0, 0, 5, 5 }); + } + scene.canvas->rect({ 0, 0, 1, 1 }, RGBA(1, 1, 1, 1)); + + CHECK(!scene.last().clipped); + } + + TEST(OutsideClipIsReportedInLocalCoordinates) { + RecordingScene scene; + const Canvas::Clip clip(*scene.canvas, { 0, 0, 50, 50 }); + const Canvas::Translation translation(*scene.canvas, { 40, 0 }); + + CHECK(!scene.canvas->isOutsideClip({ 0, 0, 20, 20 })); + CHECK(scene.canvas->isOutsideClip({ 20, 0, 20, 20 })); + } + + TEST(TextOutsideTheClipIsNotDrawn) { + RecordingScene scene; + const Canvas::Clip clip(*scene.canvas, { 0, 0, 50, 50 }); + scene.canvas->text("hidden", { 100, 100, 40, 20 }, TextStyle{}); + + CHECK(!scene.backend->hasText("hidden")); + } + + TEST(TextIsClippedToItsPaddedArea) { + RecordingScene scene; + TextStyle style; + style.padding = 2; + scene.canvas->text("label", { 10, 10, 40, 20 }, style); + + const CanvasCommand* command = scene.backend->findText("label"); + REQUIRE CHECK(command); + checkRect({ 12, 12, 36, 16 }, command->rect); + CHECK(command->clipped); + checkRect({ 12, 12, 36, 16 }, command->clip); + } + + TEST(SavedStateRestoresCompositeOperation) { + RecordingScene scene; + { + const Canvas::SavedState saved(*scene.canvas); + scene.canvas->setCompositeOperation(Canvas::CompositeOperation::DestinationOut); + scene.canvas->beginPath(); + scene.canvas->addPathRect({ 0, 0, 1, 1 }); + scene.canvas->fillPath(RGBA(0, 0, 0, 1)); + } + CHECK(scene.last().composite == Canvas::CompositeOperation::DestinationOut); + + scene.canvas->rect({ 0, 0, 1, 1 }, RGBA(1, 1, 1, 1)); + CHECK(scene.last().composite == Canvas::CompositeOperation::SourceOver); + } + + TEST(PointsAndGradientsFollowTheOrigin) { + RecordingScene scene; + const Canvas::Translation translation(*scene.canvas, { 5, 5 }); + scene.canvas->polyline({ { 0, 0 }, { 10, 0 } }, RGBA(1, 1, 1, 1), 2); + + REQUIRE CHECK_EQUAL(2u, scene.last().points.size()); + CHECK_CLOSE(5, scene.last().points[0].x, kTolerance); + CHECK_CLOSE(15, scene.last().points[1].x, kTolerance); + CHECK_CLOSE(2, scene.last().width, kTolerance); + } + + TEST(GradientTakesAnAxis) { + RecordingScene scene; + scene.canvas->gradientRect({ 0, 0, 10, 10 }, RGBA(0, 0, 0, 1), RGBA(1, 1, 1, 1), Axis::Vertical); + + CHECK(scene.last().kind == CanvasCommand::Kind::Gradient); + CHECK_CLOSE(1, scene.last().endColor.r, kTolerance); + } + + TEST(InvalidImagesAreIgnored) { + RecordingScene scene; + scene.canvas->drawImage({ 0, 0, 1, 1 }, ImageHandle()); + scene.canvas->deleteImage(ImageHandle()); + + CHECK(scene.backend->commands().empty()); + CHECK_CLOSE(0, scene.canvas->imageSize(ImageHandle()).x, kTolerance); + } + + TEST(ImagesLiveUntilDeleted) { + RecordingScene scene; + const unsigned char pixel[4] = { 255, 255, 255, 255 }; + const ImageHandle image = scene.canvas->createImageFromMemoryRGBA(3, 2, pixel); + + REQUIRE CHECK(image.isValid()); + CHECK_CLOSE(3, scene.canvas->imageSize(image).x, kTolerance); + CHECK_EQUAL(1u, scene.backend->liveImageCount()); + + scene.canvas->deleteImage(image); + CHECK_EQUAL(0u, scene.backend->liveImageCount()); + } + + TEST(ImageFilesLoadOnlyWhenKnown) { + RecordingScene scene; + scene.backend->addImageFile("icon.png", { 16, 16 }); + + CHECK(scene.canvas->createImageFromFile("icon.png").isValid()); + CHECK(!scene.canvas->createImageFromFile("missing.png").isValid()); + } + + TEST(TextMeasurementIsAvailableWithoutAFrame) { + RecordingScene scene; + const TextMetrics& metrics = *scene.canvas; + + CHECK_CLOSE( + 4 * 10 * RecordingCanvasBackend::kCharacterWidthFactor, + metrics.textWidth("abcd", 10, FontFace::Regular), + kTolerance + ); + + const std::vector prefixes = metrics.prefixWidths("ab", 10, FontFace::Regular); + REQUIRE CHECK_EQUAL(3u, prefixes.size()); + CHECK_CLOSE(0, prefixes[0], kTolerance); + CHECK_CLOSE(10, prefixes[2], kTolerance); + } + + TEST(BeginFrameResetsCompositeOperation) { + RecordingScene scene; + scene.canvas->setCompositeOperation(Canvas::CompositeOperation::DestinationOver); + scene.canvas->beginFrame(); + scene.canvas->rect({ 1, 1, 1, 1 }, RGBA(1, 1, 1, 1)); + + CHECK(scene.last().composite == Canvas::CompositeOperation::SourceOver); + CHECK(!scene.last().clipped); + } +} -- 2.49.1 From 36e4da047264c370d02b3da1b90c9e1ada02fb53 Mon Sep 17 00:00:00 2001 From: agent Date: Fri, 30 Oct 2026 04:31:44 +0300 Subject: [PATCH 2/3] GraphicApplication: track modifier keys in the input state KeyModifiers (shift, control, alt, super; either side of the keyboard counts) and a ModifierTracker that follows modifier presses in event order. InputState keeps one, so consumers read the modifiers held at the end of the frame, and walk events() with their own tracker when they need per-event modifiers. Losing keyboard focus releases them. --- .../src/input_layer/private/InputState.cpp | 16 +++++- .../src/input_layer/private/KeyModifiers.cpp | 56 +++++++++++++++++++ .../src/input_layer/public/InputState.hpp | 5 ++ .../src/input_layer/public/KeyModifiers.hpp | 45 +++++++++++++++ GraphicApplication/tests/InputStateTests.cpp | 52 +++++++++++++++++ 5 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 GraphicApplication/src/input_layer/private/KeyModifiers.cpp create mode 100644 GraphicApplication/src/input_layer/public/KeyModifiers.hpp diff --git a/GraphicApplication/src/input_layer/private/InputState.cpp b/GraphicApplication/src/input_layer/private/InputState.cpp index 51e052c8..b0fc0d58 100644 --- a/GraphicApplication/src/input_layer/private/InputState.cpp +++ b/GraphicApplication/src/input_layer/private/InputState.cpp @@ -67,13 +67,23 @@ void InputState::apply(const InputEvent& event) { [this](const KeyChanged& changed) { ButtonState& state = mKeys[(size_t) changed.key]; switch (changed.action) { - case KeyAction::Press: press(state); break; - case KeyAction::Release: release(state); break; + case KeyAction::Press: + press(state); + mModifiers.press(changed.key); + break; + case KeyAction::Release: + release(state); + mModifiers.release(changed.key); + break; case KeyAction::Repeat: break; } }, [](const TextEntered&) {}, - [](const KeyboardFocusChanged&) {}, + [this](const KeyboardFocusChanged& focus) { + if (!focus.focused) { + mModifiers.releaseAll(); + } + }, [](const GestureChanged&) {}, }, event diff --git a/GraphicApplication/src/input_layer/private/KeyModifiers.cpp b/GraphicApplication/src/input_layer/private/KeyModifiers.cpp new file mode 100644 index 00000000..8851d41d --- /dev/null +++ b/GraphicApplication/src/input_layer/private/KeyModifiers.cpp @@ -0,0 +1,56 @@ +#include "KeyModifiers.hpp" + +using namespace tp; + +bool tp::isModifierKey(Key key) { + switch (key) { + case Key::LeftShift: + case Key::RightShift: + case Key::LeftControl: + case Key::RightControl: + case Key::LeftAlt: + case Key::RightAlt: + case Key::LeftSuper: + case Key::RightSuper: return true; + default: return false; + } +} + +int ModifierTracker::slotOf(Key key) { + switch (key) { + case Key::LeftShift: return LeftShift; + case Key::RightShift: return RightShift; + case Key::LeftControl: return LeftControl; + case Key::RightControl: return RightControl; + case Key::LeftAlt: return LeftAlt; + case Key::RightAlt: return RightAlt; + case Key::LeftSuper: return LeftSuper; + case Key::RightSuper: return RightSuper; + default: return -1; + } +} + +void ModifierTracker::press(Key key) { + const int slot = slotOf(key); + if (slot >= 0) { + mDown.set((size_t) slot); + } +} + +void ModifierTracker::release(Key key) { + const int slot = slotOf(key); + if (slot >= 0) { + mDown.reset((size_t) slot); + } +} + +bool ModifierTracker::isDown(Key key) const { return mDown.test((size_t) slotOf(key)); } + +KeyModifiers ModifierTracker::current() const { + KeyModifiers modifiers; + modifiers.shift = isDown(Key::LeftShift) || isDown(Key::RightShift); + modifiers.control = isDown(Key::LeftControl) || isDown(Key::RightControl); + modifiers.alt = isDown(Key::LeftAlt) || isDown(Key::RightAlt); + modifiers.super = isDown(Key::LeftSuper) || isDown(Key::RightSuper); + return modifiers; +} diff --git a/GraphicApplication/src/input_layer/public/InputState.hpp b/GraphicApplication/src/input_layer/public/InputState.hpp index e58f6310..9894c666 100644 --- a/GraphicApplication/src/input_layer/public/InputState.hpp +++ b/GraphicApplication/src/input_layer/public/InputState.hpp @@ -1,6 +1,7 @@ #pragma once #include "InputSink.hpp" +#include "KeyModifiers.hpp" #include #include @@ -30,6 +31,9 @@ namespace tp { [[nodiscard]] const ButtonState& button(PointerButton button) const { return mPointerButtons[(size_t) button]; } [[nodiscard]] const ButtonState& key(Key key) const { return mKeys[(size_t) key]; } + // Modifiers held at the end of the frame; walk events() with a ModifierTracker for per-event modifiers. + [[nodiscard]] KeyModifiers modifiers() const { return mModifiers.current(); } + [[nodiscard]] Vec2F scroll() const { return mScrollDelta; } [[nodiscard]] bool isContinuousScroll() const { return mContinuousScroll; } [[nodiscard]] bool isScrollEnded() const { return mScrollEnded; } @@ -52,6 +56,7 @@ namespace tp { std::array mPointerButtons{}; std::array mKeys{}; + ModifierTracker mModifiers; }; // Collects delivered events between frames and releases held buttons and keys when focus or the pointer is lost. diff --git a/GraphicApplication/src/input_layer/public/KeyModifiers.hpp b/GraphicApplication/src/input_layer/public/KeyModifiers.hpp new file mode 100644 index 00000000..eca98f91 --- /dev/null +++ b/GraphicApplication/src/input_layer/public/KeyModifiers.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "InputCodes.hpp" + +#include + +namespace tp { + + // Which modifier keys are held; either side of the keyboard counts. + struct KeyModifiers { + bool shift = false; + bool control = false; + bool alt = false; + bool super = false; + + [[nodiscard]] bool none() const { return !shift && !control && !alt && !super; } + [[nodiscard]] bool operator==(const KeyModifiers& other) const = default; + + [[nodiscard]] static KeyModifiers shiftOnly() { return { true, false, false, false }; } + [[nodiscard]] static KeyModifiers controlOnly() { return { false, true, false, false }; } + [[nodiscard]] static KeyModifiers altOnly() { return { false, false, true, false }; } + }; + + [[nodiscard]] bool isModifierKey(Key key); + + // Follows modifier key presses and releases in event order, so every event can be read with the modifiers held at + // that moment rather than at the end of the frame. + class ModifierTracker { + public: + void press(Key key); + void release(Key key); + void releaseAll() { mDown.reset(); } + + [[nodiscard]] KeyModifiers current() const; + + private: + [[nodiscard]] bool isDown(Key key) const; + + enum Slot { LeftShift, RightShift, LeftControl, RightControl, LeftAlt, RightAlt, LeftSuper, RightSuper, SlotCount }; + + [[nodiscard]] static int slotOf(Key key); + + std::bitset mDown; + }; +} diff --git a/GraphicApplication/tests/InputStateTests.cpp b/GraphicApplication/tests/InputStateTests.cpp index 8868a05c..1c5ffd16 100644 --- a/GraphicApplication/tests/InputStateTests.cpp +++ b/GraphicApplication/tests/InputStateTests.cpp @@ -126,3 +126,55 @@ SUITE(InputState) { CHECK_CLOSE(30, hidden.relativeTo({ 0, 0 }).getPointer().x, 0.0001f); } } + +SUITE(KeyModifiers) { + TEST(EitherSideOfTheKeyboardCounts) { + ModifierTracker tracker; + tracker.press(Key::RightControl); + CHECK(tracker.current().control); + + tracker.press(Key::LeftControl); + tracker.release(Key::RightControl); + CHECK(tracker.current().control); + + tracker.release(Key::LeftControl); + CHECK(tracker.current().none()); + } + + TEST(OtherKeysAreNotModifiers) { + ModifierTracker tracker; + tracker.press(Key::A); + CHECK(tracker.current().none()); + CHECK(!isModifierKey(Key::A)); + CHECK(isModifierKey(Key::RightSuper)); + } + + TEST(StateHoldsModifiersAtTheEndOfTheFrame) { + InputQueue queue; + queue.post(KeyChanged{ Key::LeftShift, KeyAction::Press }); + queue.post(KeyChanged{ Key::LeftAlt, KeyAction::Press }); + queue.post(KeyChanged{ Key::LeftAlt, KeyAction::Release }); + queue.applyPendingEvents(); + + CHECK(queue.state().modifiers() == KeyModifiers::shiftOnly()); + } + + TEST(ModifiersPersistAcrossFrames) { + InputQueue queue; + queue.post(KeyChanged{ Key::LeftControl, KeyAction::Press }); + queue.applyPendingEvents(); + queue.applyPendingEvents(); + + CHECK(queue.state().modifiers().control); + } + + TEST(LosingFocusReleasesModifiers) { + InputQueue queue; + queue.post(KeyChanged{ Key::LeftControl, KeyAction::Press }); + queue.applyPendingEvents(); + queue.post(KeyboardFocusChanged{ false }); + queue.applyPendingEvents(); + + CHECK(queue.state().modifiers().none()); + } +} -- 2.49.1 From ce1bd316c9a1d2e5f63a5ad06442daefb8241a46 Mon Sep 17 00:00:00 2001 From: agent Date: Fri, 30 Oct 2026 04:37:43 +0300 Subject: [PATCH 3/3] GraphicApplication: move window vocabulary into its own library WindowTypes (cursor shapes, clipboard image, window identity and edges) and PlatformInfo move from the backend contract into a WindowTypes library that needs only Math. Code that names a cursor or reads platform facts no longer links the GPU device and the backend contract. --- GraphicApplication/CMakeLists.txt | 6 +++++- .../backends/contract/private/WindowFrame.cpp | 15 --------------- .../private/PlatformInfo.cpp | 0 .../src/window_types/private/WindowTypes.cpp | 18 ++++++++++++++++++ .../public/PlatformInfo.hpp | 0 .../public/WindowTypes.hpp | 0 6 files changed, 23 insertions(+), 16 deletions(-) rename GraphicApplication/src/{backends/contract => window_types}/private/PlatformInfo.cpp (100%) create mode 100644 GraphicApplication/src/window_types/private/WindowTypes.cpp rename GraphicApplication/src/{backends/contract => window_types}/public/PlatformInfo.hpp (100%) rename GraphicApplication/src/{backends/contract => window_types}/public/WindowTypes.hpp (100%) diff --git a/GraphicApplication/CMakeLists.txt b/GraphicApplication/CMakeLists.txt index 768ed6f6..640563c3 100644 --- a/GraphicApplication/CMakeLists.txt +++ b/GraphicApplication/CMakeLists.txt @@ -17,6 +17,10 @@ endfunction() graphic_application_library(${PROJECT_NAME}InputLayer input_layer) target_link_libraries(${PROJECT_NAME}InputLayer PUBLIC Math Modules) +### ---------------------- Window vocabulary (cursor, clipboard, platform facts) --------------------- ### +graphic_application_library(${PROJECT_NAME}WindowTypes window_types) +target_link_libraries(${PROJECT_NAME}WindowTypes PUBLIC Math) + ### ---------------------- Diagnostics --------------------- ### graphic_application_library(${PROJECT_NAME}Diagnostics diagnostics) target_link_libraries(${PROJECT_NAME}Diagnostics PUBLIC Callstack Threads::Threads) @@ -71,7 +75,7 @@ endif () ### ---------------------- Backend contract --------------------- ### graphic_application_library(${PROJECT_NAME}BackendContract backends/contract) -target_link_libraries(${PROJECT_NAME}BackendContract PUBLIC Math ${PROJECT_NAME}InputLayer ${PROJECT_NAME}Gpu) +target_link_libraries(${PROJECT_NAME}BackendContract PUBLIC Math ${PROJECT_NAME}InputLayer ${PROJECT_NAME}WindowTypes ${PROJECT_NAME}Gpu) ### ---------------------- Platform backend --------------------- ### if (ANDROID) diff --git a/GraphicApplication/src/backends/contract/private/WindowFrame.cpp b/GraphicApplication/src/backends/contract/private/WindowFrame.cpp index 584649ad..2243e6b0 100644 --- a/GraphicApplication/src/backends/contract/private/WindowFrame.cpp +++ b/GraphicApplication/src/backends/contract/private/WindowFrame.cpp @@ -4,21 +4,6 @@ using namespace tp; -CursorShape tp::cursorForEdge(WindowEdge edge) { - switch (edge) { - case WindowEdge::Left: - case WindowEdge::Right: return CursorShape::ResizeHorizontal; - case WindowEdge::Top: - case WindowEdge::Bottom: return CursorShape::ResizeVertical; - case WindowEdge::TopLeft: - case WindowEdge::BottomRight: return CursorShape::ResizeNorthWestSouthEast; - case WindowEdge::TopRight: - case WindowEdge::BottomLeft: return CursorShape::ResizeNorthEastSouthWest; - case WindowEdge::None: return CursorShape::Default; - } - return CursorShape::Default; -} - Vec2F WindowFrame::surfaceSize() const { const halnf inset = margin(); return { mContentSize.x + inset * 2, mContentSize.y + inset * 2 }; diff --git a/GraphicApplication/src/backends/contract/private/PlatformInfo.cpp b/GraphicApplication/src/window_types/private/PlatformInfo.cpp similarity index 100% rename from GraphicApplication/src/backends/contract/private/PlatformInfo.cpp rename to GraphicApplication/src/window_types/private/PlatformInfo.cpp diff --git a/GraphicApplication/src/window_types/private/WindowTypes.cpp b/GraphicApplication/src/window_types/private/WindowTypes.cpp new file mode 100644 index 00000000..a5fe25f0 --- /dev/null +++ b/GraphicApplication/src/window_types/private/WindowTypes.cpp @@ -0,0 +1,18 @@ +#include "WindowTypes.hpp" + +using namespace tp; + +CursorShape tp::cursorForEdge(WindowEdge edge) { + switch (edge) { + case WindowEdge::Left: + case WindowEdge::Right: return CursorShape::ResizeHorizontal; + case WindowEdge::Top: + case WindowEdge::Bottom: return CursorShape::ResizeVertical; + case WindowEdge::TopLeft: + case WindowEdge::BottomRight: return CursorShape::ResizeNorthWestSouthEast; + case WindowEdge::TopRight: + case WindowEdge::BottomLeft: return CursorShape::ResizeNorthEastSouthWest; + case WindowEdge::None: return CursorShape::Default; + } + return CursorShape::Default; +} diff --git a/GraphicApplication/src/backends/contract/public/PlatformInfo.hpp b/GraphicApplication/src/window_types/public/PlatformInfo.hpp similarity index 100% rename from GraphicApplication/src/backends/contract/public/PlatformInfo.hpp rename to GraphicApplication/src/window_types/public/PlatformInfo.hpp diff --git a/GraphicApplication/src/backends/contract/public/WindowTypes.hpp b/GraphicApplication/src/window_types/public/WindowTypes.hpp similarity index 100% rename from GraphicApplication/src/backends/contract/public/WindowTypes.hpp rename to GraphicApplication/src/window_types/public/WindowTypes.hpp -- 2.49.1