diff --git a/3DEditor/CMakeLists.txt b/3DEditor/CMakeLists.txt index 1a410b4c..54adad14 100644 --- a/3DEditor/CMakeLists.txt +++ b/3DEditor/CMakeLists.txt @@ -18,7 +18,7 @@ target_link_libraries(${PROJECT_NAME} PRIVATE ${BINDINGS_LIBS}) file(GLOB APP_SOURCES "./applications/*.cpp") add_executable(3DEditorApp ${APP_SOURCES}) target_link_libraries(3DEditorApp ${PROJECT_NAME}) +graphic_application_resources(3DEditorApp) file(COPY "../RasterRender/rsc" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/") -file(COPY "../Graphics/rsc" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/") file(COPY "../3DScene/rsc" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/") diff --git a/3DEditor/applications/Entry.cpp b/3DEditor/applications/Entry.cpp index 004bae33..6867377b 100644 --- a/3DEditor/applications/Entry.cpp +++ b/3DEditor/applications/Entry.cpp @@ -9,7 +9,7 @@ using namespace tp; class EditorGUI : public WidgetApplication { public: EditorGUI() { - auto canvas = this->mGraphics->getCanvas(); + auto canvas = &this->canvas(); mGui = new EditorWidget(canvas, &mEditor); mEditor.loadDefaults(); diff --git a/3DEditor/public/EditorWidget.hpp b/3DEditor/public/EditorWidget.hpp index 7b4e7485..48b4568a 100644 --- a/3DEditor/public/EditorWidget.hpp +++ b/3DEditor/public/EditorWidget.hpp @@ -10,28 +10,28 @@ namespace tp { public: explicit ViewportWidget(Canvas* canvas, Editor* editor) { mEditor = editor; - mImage = canvas->createImageFromTextId(mEditor->getViewportTexID(), { 0, 0 }); + mImage = canvas->createImageFromRenderTarget(mEditor->getViewportTexID()); mCanvas = canvas; } - ~ViewportWidget() override { mCanvas->deleteImageHandle(mImage); } + ~ViewportWidget() override { mCanvas->deleteImage(mImage); } - void process(const EventHandler& events) override { + void process(const InputView& events) override { mEditor->setViewportSize(getAreaT().size); } void draw(Canvas& canvas) override { mEditor->renderViewport(); - canvas.updateTextureID(mImage, mEditor->getViewportTexID()); - canvas.drawImage(getArea().relative(), &mImage, PI); + canvas.updateImageFromRenderTarget(mImage, mEditor->getViewportTexID()); + canvas.drawImage(getArea().relative(), mImage, PI); } public: Editor* mEditor; Canvas* mCanvas = nullptr; - Canvas::ImageHandle mImage; + ImageHandle mImage; }; class EditorWidget : public DockWidget { @@ -91,7 +91,7 @@ namespace tp { mNavigationReset.setAction( [this]() { mEditor->navigationReset(); }); } - void process(const EventHandler& events) override { + void process(const InputView& events) override { DockWidget::process(events); if (auto obj = mEditor->getActiveObject()) { // dummy rotate active object @@ -107,7 +107,7 @@ namespace tp { auto pointerPrev = events.getPointerPrev(); auto pointerRelative = (((pointer - activeArea.pos) / activeArea.size) - 0.5f) * 2; - if (activeArea.isInside(pointer) && events.isDown(InputID::MOUSE1)) { + if (activeArea.isInside(pointer) && events.isDown(PointerButton::Left)) { switch (mNavigationType) { case ORBIT: mEditor->navigationOrbit(events.getPointerDelta() / activeArea.size * 3); break; diff --git a/CMakeLists.txt b/CMakeLists.txt index db5b4851..be7af340 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,7 +27,7 @@ add_subdirectory(Allocators) add_subdirectory(Strings) add_subdirectory(Language) add_subdirectory(Connection) -add_subdirectory(Graphics) +add_subdirectory(GraphicApplication) add_subdirectory(DataAnalysis) add_subdirectory(Objects) add_subdirectory(Widgets) diff --git a/GraphicApplication/.clang-format b/GraphicApplication/.clang-format new file mode 100644 index 00000000..98267ea4 --- /dev/null +++ b/GraphicApplication/.clang-format @@ -0,0 +1,4 @@ +--- +BasedOnStyle: InheritParentConfig +AllowShortIfStatementsOnASingleLine: Never +InsertBraces: true diff --git a/GraphicApplication/CMakeLists.txt b/GraphicApplication/CMakeLists.txt new file mode 100644 index 00000000..ccfd36a4 --- /dev/null +++ b/GraphicApplication/CMakeLists.txt @@ -0,0 +1,133 @@ +project(GraphicApplication) + +find_package(Vulkan REQUIRED) +find_package(Threads REQUIRED) + +function(graphic_application_library TARGET_NAME LIBRARY_DIR) + set(DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/${LIBRARY_DIR}) + file(GLOB SOURCES CONFIGURE_DEPENDS ${DIR}/private/*.cpp ${DIR}/private/*.hpp ${DIR}/public/*.hpp) + add_library(${TARGET_NAME} STATIC ${SOURCES}) + if (EXISTS ${DIR}/public) + target_include_directories(${TARGET_NAME} PUBLIC ${DIR}/public) + endif () + target_include_directories(${TARGET_NAME} PRIVATE ${DIR}/private) +endfunction() + +### ---------------------- Input layer --------------------- ### +graphic_application_library(${PROJECT_NAME}InputLayer input_layer) +target_link_libraries(${PROJECT_NAME}InputLayer PUBLIC Math Modules) + +### ---------------------- Diagnostics --------------------- ### +graphic_application_library(${PROJECT_NAME}Diagnostics diagnostics) +target_link_libraries(${PROJECT_NAME}Diagnostics PUBLIC Callstack Threads::Threads) + +### ---------------------- GPU device and window surfaces --------------------- ### +graphic_application_library(${PROJECT_NAME}Gpu gpu) +target_link_libraries(${PROJECT_NAME}Gpu PUBLIC Vulkan::Vulkan PRIVATE ${PROJECT_NAME}Diagnostics) + +### ---------------------- Wayland protocols --------------------- ### +if (NOT ANDROID) + find_package(PkgConfig REQUIRED) + pkg_check_modules(WAYLAND REQUIRED IMPORTED_TARGET wayland-client xkbcommon) + pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir) + pkg_get_variable(WAYLAND_SCANNER wayland-scanner wayland_scanner) + + ### ---------------------- Wayland Protocols --------------------- ### + set(WAYLAND_PROTOCOL_FILES + stable/xdg-shell/xdg-shell.xml + stable/viewporter/viewporter.xml + staging/fractional-scale/fractional-scale-v1.xml + staging/cursor-shape/cursor-shape-v1.xml + staging/xdg-activation/xdg-activation-v1.xml + stable/tablet/tablet-v2.xml + unstable/text-input/text-input-unstable-v3.xml + unstable/xdg-decoration/xdg-decoration-unstable-v1.xml + unstable/pointer-gestures/pointer-gestures-unstable-v1.xml + ) + + set(WAYLAND_GENERATED_DIR ${CMAKE_CURRENT_BINARY_DIR}/wayland) + file(MAKE_DIRECTORY ${WAYLAND_GENERATED_DIR}) + + foreach(PROTOCOL_FILE ${WAYLAND_PROTOCOL_FILES}) + get_filename_component(PROTOCOL_NAME ${PROTOCOL_FILE} NAME_WE) + set(PROTOCOL_XML ${WAYLAND_PROTOCOLS_DIR}/${PROTOCOL_FILE}) + set(PROTOCOL_HEADER ${WAYLAND_GENERATED_DIR}/${PROTOCOL_NAME}-client-protocol.h) + set(PROTOCOL_SOURCE ${WAYLAND_GENERATED_DIR}/${PROTOCOL_NAME}-protocol.c) + + add_custom_command( + OUTPUT ${PROTOCOL_HEADER} ${PROTOCOL_SOURCE} + COMMAND ${WAYLAND_SCANNER} client-header ${PROTOCOL_XML} ${PROTOCOL_HEADER} + COMMAND ${WAYLAND_SCANNER} private-code ${PROTOCOL_XML} ${PROTOCOL_SOURCE} + DEPENDS ${PROTOCOL_XML} + ) + list(APPEND WAYLAND_PROTOCOL_SOURCES ${PROTOCOL_HEADER} ${PROTOCOL_SOURCE}) + endforeach() + + add_library(WaylandProtocols STATIC ${WAYLAND_PROTOCOL_SOURCES}) + target_include_directories(WaylandProtocols PUBLIC ${WAYLAND_GENERATED_DIR}) + target_link_libraries(WaylandProtocols PUBLIC PkgConfig::WAYLAND) + set_target_properties(WaylandProtocols PROPERTIES LINKER_LANGUAGE C) +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) + +### ---------------------- Platform backend --------------------- ### +if (ANDROID) + graphic_application_library(${PROJECT_NAME}Backend backends/android) + target_link_libraries(${PROJECT_NAME}Backend PUBLIC native_app_glue PRIVATE android log) +else () + graphic_application_library(${PROJECT_NAME}Backend backends/wayland) + target_link_libraries(${PROJECT_NAME}Backend PRIVATE WaylandProtocols PkgConfig::WAYLAND) +endif () +target_link_libraries(${PROJECT_NAME}Backend PUBLIC ${PROJECT_NAME}BackendContract) + +### ---------------------- Window --------------------- ### +graphic_application_library(${PROJECT_NAME}Window window) +target_link_libraries(${PROJECT_NAME}Window PUBLIC Math ${PROJECT_NAME}BackendContract PRIVATE ${PROJECT_NAME}Backend) + +### ---------------------- Canvas --------------------- ### +graphic_application_library(${PROJECT_NAME}Canvas canvas) +target_link_libraries(${PROJECT_NAME}Canvas PUBLIC Math Containers ${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) + +### ---------------------- Debug UI --------------------- ### +graphic_application_library(${PROJECT_NAME}DebugUI debug_ui) +target_link_libraries(${PROJECT_NAME}DebugUI PUBLIC ${PROJECT_NAME}Window PRIVATE Imgui) + +### ---------------------- 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_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. +set(GRAPHIC_APPLICATION_RESOURCES ${CMAKE_CURRENT_SOURCE_DIR}/rsc CACHE INTERNAL "") +function(graphic_application_resources TARGET_NAME) + add_custom_command(TARGET ${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory ${GRAPHIC_APPLICATION_RESOURCES} $/rsc + ) +endfunction() + +### -------------------------- Examples -------------------------- ### +if (NOT ANDROID) + add_executable(Example${PROJECT_NAME} ./examples/Example.cpp) + target_link_libraries(Example${PROJECT_NAME} ${PROJECT_NAME} Imgui) + graphic_application_resources(Example${PROJECT_NAME}) + + add_executable(OffscreenProof ./examples/OffscreenProof.cpp) + target_link_libraries(OffscreenProof NanovgVk Vulkan::Vulkan) +endif () + +### -------------------------- Tests -------------------------- ### +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_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++) + add_test(NAME Test${PROJECT_NAME} COMMAND Test${PROJECT_NAME}) +endif () diff --git a/GraphicApplication/examples/Example.cpp b/GraphicApplication/examples/Example.cpp new file mode 100644 index 00000000..84e7530e --- /dev/null +++ b/GraphicApplication/examples/Example.cpp @@ -0,0 +1,25 @@ +#include "GraphicApplication.hpp" + +#include "imgui.h" + +using namespace tp; + +class ExampleDelegate : public ApplicationDelegate { +public: + void attach(Application& application) { mApplication = &application; } + + void drawFrame(Canvas&) override { + ImGui::ShowDemoWindow(); + mApplication->showFrameRates(); + } + +private: + Application* mApplication = nullptr; +}; + +int main() { + ExampleDelegate delegate; + Application application(delegate); + delegate.attach(application); + application.run(); +} diff --git a/GraphicApplication/examples/OffscreenProof.cpp b/GraphicApplication/examples/OffscreenProof.cpp new file mode 100644 index 00000000..c91be7d2 --- /dev/null +++ b/GraphicApplication/examples/OffscreenProof.cpp @@ -0,0 +1,378 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + + constexpr uint32_t kWidth = 256; + constexpr uint32_t kHeight = 256; + constexpr VkFormat kColorFormat = VK_FORMAT_R8G8B8A8_UNORM; + constexpr VkFormat kDepthFormat = VK_FORMAT_D24_UNORM_S8_UINT; + + void check(VkResult result, const char* what) { + if (result != VK_SUCCESS) { + throw std::runtime_error(std::string(what) + " failed"); + } + } + + uint32_t findMemoryType(VkPhysicalDevice gpu, uint32_t filter, VkMemoryPropertyFlags flags) { + VkPhysicalDeviceMemoryProperties properties{}; + vkGetPhysicalDeviceMemoryProperties(gpu, &properties); + for (uint32_t i = 0; i < properties.memoryTypeCount; i++) { + if ((filter & (1 << i)) && (properties.memoryTypes[i].propertyFlags & flags) == flags) { + return i; + } + } + throw std::runtime_error("no memory type"); + } + + struct Image { + VkImage image = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + VkImageView view = VK_NULL_HANDLE; + }; + + Image createImage( + VkPhysicalDevice gpu, VkDevice device, VkFormat format, VkImageUsageFlags usage, VkImageAspectFlags aspect + ) { + Image out; + VkImageCreateInfo info{ + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .imageType = VK_IMAGE_TYPE_2D, + .format = format, + .extent = { kWidth, kHeight, 1 }, + .mipLevels = 1, + .arrayLayers = 1, + .samples = VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .usage = usage, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + }; + check(vkCreateImage(device, &info, nullptr, &out.image), "vkCreateImage"); + + VkMemoryRequirements req{}; + vkGetImageMemoryRequirements(device, out.image, &req); + VkMemoryAllocateInfo alloc{ + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = req.size, + .memoryTypeIndex = findMemoryType(gpu, req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT), + }; + check(vkAllocateMemory(device, &alloc, nullptr, &out.memory), "vkAllocateMemory"); + vkBindImageMemory(device, out.image, out.memory, 0); + + VkImageViewCreateInfo viewInfo{ + .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, + .image = out.image, + .viewType = VK_IMAGE_VIEW_TYPE_2D, + .format = format, + .subresourceRange = { aspect, 0, 1, 0, 1 }, + }; + check(vkCreateImageView(device, &viewInfo, nullptr, &out.view), "vkCreateImageView"); + return out; + } + + void writePpm(const char* path, const std::vector& rgba) { + FILE* file = std::fopen(path, "wb"); + std::fprintf(file, "P6\n%u %u\n255\n", kWidth, kHeight); + for (uint32_t i = 0; i < kWidth * kHeight; i++) { + std::fputc(rgba[i * 4 + 0], file); + std::fputc(rgba[i * 4 + 1], file); + std::fputc(rgba[i * 4 + 2], file); + } + std::fclose(file); + } + +} + +int main() { + VkApplicationInfo appInfo{ .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, .apiVersion = VK_API_VERSION_1_2 }; + VkInstanceCreateInfo instanceInfo{ .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pApplicationInfo = &appInfo }; + VkInstance instance = VK_NULL_HANDLE; + check(vkCreateInstance(&instanceInfo, nullptr, &instance), "vkCreateInstance"); + + uint32_t gpuCount = 0; + vkEnumeratePhysicalDevices(instance, &gpuCount, nullptr); + std::vector gpus(gpuCount); + vkEnumeratePhysicalDevices(instance, &gpuCount, gpus.data()); + VkPhysicalDevice gpu = gpus.at(0); + + uint32_t familyCount = 0; + vkGetPhysicalDeviceQueueFamilyProperties(gpu, &familyCount, nullptr); + std::vector families(familyCount); + vkGetPhysicalDeviceQueueFamilyProperties(gpu, &familyCount, families.data()); + uint32_t graphicsFamily = 0; + for (uint32_t i = 0; i < familyCount; i++) { + if (families[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) { + graphicsFamily = i; + break; + } + } + + uint32_t deviceExtCount = 0; + vkEnumerateDeviceExtensionProperties(gpu, nullptr, &deviceExtCount, nullptr); + std::vector deviceExts(deviceExtCount); + vkEnumerateDeviceExtensionProperties(gpu, nullptr, &deviceExtCount, deviceExts.data()); + bool hasExtendedDynamicState = false; + for (const auto& extension : deviceExts) { + if (std::strcmp(extension.extensionName, VK_EXT_EXTENDED_DYNAMIC_STATE_EXTENSION_NAME) == 0) { + hasExtendedDynamicState = true; + } + } + + std::vector enabledDeviceExts; + if (hasExtendedDynamicState) { + enabledDeviceExts.push_back(VK_EXT_EXTENDED_DYNAMIC_STATE_EXTENSION_NAME); + } + + VkPhysicalDeviceExtendedDynamicStateFeaturesEXT dynamicStateFeatures{ + .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTENDED_DYNAMIC_STATE_FEATURES_EXT, + .extendedDynamicState = VK_TRUE, + }; + + const float priority = 1.0f; + VkDeviceQueueCreateInfo queueInfo{ + .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, + .queueFamilyIndex = graphicsFamily, + .queueCount = 1, + .pQueuePriorities = &priority, + }; + VkDeviceCreateInfo deviceInfo{ + .sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, + .pNext = hasExtendedDynamicState ? &dynamicStateFeatures : nullptr, + .queueCreateInfoCount = 1, + .pQueueCreateInfos = &queueInfo, + .enabledExtensionCount = static_cast(enabledDeviceExts.size()), + .ppEnabledExtensionNames = enabledDeviceExts.data(), + }; + VkDevice device = VK_NULL_HANDLE; + check(vkCreateDevice(gpu, &deviceInfo, nullptr, &device), "vkCreateDevice"); + std::printf("extended_dynamic_state: %s\n", hasExtendedDynamicState ? "enabled" : "unavailable"); + + VkQueue queue = VK_NULL_HANDLE; + vkGetDeviceQueue(device, graphicsFamily, 0, &queue); + + Image color = createImage( + gpu, + device, + kColorFormat, + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT, + VK_IMAGE_ASPECT_COLOR_BIT + ); + Image depth = createImage( + gpu, + device, + kDepthFormat, + VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, + VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT + ); + + const std::array attachments = { + VkAttachmentDescription{ + .format = kColorFormat, + .samples = VK_SAMPLE_COUNT_1_BIT, + .loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, + .storeOp = VK_ATTACHMENT_STORE_OP_STORE, + .stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE, + .stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .finalLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + }, + VkAttachmentDescription{ + .format = kDepthFormat, + .samples = VK_SAMPLE_COUNT_1_BIT, + .loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, + .storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, + .stencilLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, + .stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, + }, + }; + VkAttachmentReference colorRef{ 0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL }; + VkAttachmentReference depthRef{ 1, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL }; + VkSubpassDescription subpass{ + .pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, + .colorAttachmentCount = 1, + .pColorAttachments = &colorRef, + .pDepthStencilAttachment = &depthRef, + }; + VkRenderPassCreateInfo renderPassInfo{ + .sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, + .attachmentCount = static_cast(attachments.size()), + .pAttachments = attachments.data(), + .subpassCount = 1, + .pSubpasses = &subpass, + }; + VkRenderPass renderPass = VK_NULL_HANDLE; + check(vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass), "vkCreateRenderPass"); + + const std::array framebufferViews = { color.view, depth.view }; + VkFramebufferCreateInfo framebufferInfo{ + .sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, + .renderPass = renderPass, + .attachmentCount = static_cast(framebufferViews.size()), + .pAttachments = framebufferViews.data(), + .width = kWidth, + .height = kHeight, + .layers = 1, + }; + VkFramebuffer framebuffer = VK_NULL_HANDLE; + check(vkCreateFramebuffer(device, &framebufferInfo, nullptr, &framebuffer), "vkCreateFramebuffer"); + + VkCommandPoolCreateInfo poolInfo{ + .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, + .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, + .queueFamilyIndex = graphicsFamily, + }; + VkCommandPool pool = VK_NULL_HANDLE; + check(vkCreateCommandPool(device, &poolInfo, nullptr, &pool), "vkCreateCommandPool"); + + std::array commandBuffers{}; + VkCommandBufferAllocateInfo cmdAlloc{ + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = pool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1, + }; + check(vkAllocateCommandBuffers(device, &cmdAlloc, commandBuffers.data()), "vkAllocateCommandBuffers"); + + uint32_t currentFrame = 0; + VKNVGCreateInfo nvgInfo{}; + nvgInfo.gpu = gpu; + nvgInfo.device = device; + nvgInfo.renderpass = renderPass; + nvgInfo.cmdBuffer = commandBuffers.data(); + nvgInfo.graphicsQueueFamilyIndex = graphicsFamily; + nvgInfo.currentFrame = ¤tFrame; + nvgInfo.swapchainImageCount = 1; + nvgInfo.ext.dynamicState = hasExtendedDynamicState; + NVGcontext* vg = nvgCreateVk(&nvgInfo, NVG_ANTIALIAS | NVG_STENCIL_STROKES, queue); + if (vg == nullptr) { + throw std::runtime_error("nvgCreateVk returned null"); + } + + VkCommandBuffer cmd = commandBuffers[0]; + VkCommandBufferBeginInfo begin{ .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO }; + vkBeginCommandBuffer(cmd, &begin); + + std::array clears{}; + clears[0].color = { { 0.10f, 0.10f, 0.12f, 1.0f } }; + clears[1].depthStencil = { 1.0f, 0 }; + VkRenderPassBeginInfo pass{ + .sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, + .renderPass = renderPass, + .framebuffer = framebuffer, + .renderArea = { { 0, 0 }, { kWidth, kHeight } }, + .clearValueCount = static_cast(clears.size()), + .pClearValues = clears.data(), + }; + vkCmdBeginRenderPass(cmd, &pass, VK_SUBPASS_CONTENTS_INLINE); + + VkViewport viewport{ + .x = 0.0f, + .y = 0.0f, + .width = static_cast(kWidth), + .height = static_cast(kHeight), + .minDepth = 0.0f, + .maxDepth = 1.0f, + }; + vkCmdSetViewport(cmd, 0, 1, &viewport); + VkRect2D scissor{ { 0, 0 }, { kWidth, kHeight } }; + vkCmdSetScissor(cmd, 0, 1, &scissor); + + nvgBeginFrame(vg, kWidth, kHeight, 1.0f); + + nvgBeginPath(vg); + nvgRect(vg, 40, 40, 120, 80); + nvgFillColor(vg, nvgRGBA(220, 80, 60, 255)); + nvgFill(vg); + + nvgBeginPath(vg); + nvgCircle(vg, 170, 170, 50); + nvgFillColor(vg, nvgRGBA(60, 160, 230, 255)); + nvgFill(vg); + + nvgBeginPath(vg); + nvgMoveTo(vg, 30, 220); + nvgLineTo(vg, 230, 150); + nvgStrokeColor(vg, nvgRGBA(240, 220, 40, 255)); + nvgStrokeWidth(vg, 6); + nvgStroke(vg); + + nvgEndFrame(vg); + + vkCmdEndRenderPass(cmd); + vkEndCommandBuffer(cmd); + + VkSubmitInfo submit{ + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .commandBufferCount = 1, + .pCommandBuffers = &cmd, + }; + vkQueueSubmit(queue, 1, &submit, VK_NULL_HANDLE); + vkQueueWaitIdle(queue); + + VkBufferCreateInfo bufferInfo{ + .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, + .size = static_cast(kWidth) * kHeight * 4, + .usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + }; + VkBuffer readback = VK_NULL_HANDLE; + check(vkCreateBuffer(device, &bufferInfo, nullptr, &readback), "vkCreateBuffer"); + VkMemoryRequirements bufReq{}; + vkGetBufferMemoryRequirements(device, readback, &bufReq); + VkMemoryAllocateInfo bufAlloc{ + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = bufReq.size, + .memoryTypeIndex = findMemoryType( + gpu, bufReq.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT + ), + }; + VkDeviceMemory readbackMemory = VK_NULL_HANDLE; + check(vkAllocateMemory(device, &bufAlloc, nullptr, &readbackMemory), "vkAllocateMemory readback"); + vkBindBufferMemory(device, readback, readbackMemory, 0); + + vkResetCommandBuffer(cmd, 0); + vkBeginCommandBuffer(cmd, &begin); + VkBufferImageCopy region{ + .imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 }, + .imageExtent = { kWidth, kHeight, 1 }, + }; + vkCmdCopyImageToBuffer(cmd, color.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readback, 1, ®ion); + vkEndCommandBuffer(cmd); + vkQueueSubmit(queue, 1, &submit, VK_NULL_HANDLE); + vkQueueWaitIdle(queue); + + void* mapped = nullptr; + vkMapMemory(device, readbackMemory, 0, bufferInfo.size, 0, &mapped); + std::vector pixels(bufferInfo.size); + std::memcpy(pixels.data(), mapped, bufferInfo.size); + vkUnmapMemory(device, readbackMemory); + + writePpm("nanovg_offscreen.ppm", pixels); + + uint64_t nonBackground = 0; + for (uint32_t i = 0; i < kWidth * kHeight; i++) { + const uint8_t r = pixels[i * 4 + 0]; + const uint8_t g = pixels[i * 4 + 1]; + const uint8_t b = pixels[i * 4 + 2]; + if (r > 40 || g > 40 || b > 40) { + nonBackground++; + } + } + std::printf( + "rendered %llu / %u non-background pixels\n", static_cast(nonBackground), kWidth * kHeight + ); + + nvgDeleteVk(vg); + return nonBackground > 1000 ? 0 : 1; +} diff --git a/GraphicApplication/rsc/Emoji.ttf b/GraphicApplication/rsc/Emoji.ttf new file mode 100644 index 00000000..19b7badf Binary files /dev/null and b/GraphicApplication/rsc/Emoji.ttf differ diff --git a/GraphicApplication/rsc/FONTS.md b/GraphicApplication/rsc/FONTS.md new file mode 100644 index 00000000..6a7d004e --- /dev/null +++ b/GraphicApplication/rsc/FONTS.md @@ -0,0 +1,56 @@ +# Bundled fonts + +`GraphicApplication/private/drawing/CanvasFonts.hpp` names these files, `Canvas` loads them +relative to the application's `rsc/` directory, and `GraphicApplication/tests/FontCoverage` +checks that every text face can draw the characters the UI uses and the Russian +alphabet. All of them are SIL Open Font License (see `licenses/`). + +| File | Family | Version | upem | Glyphs | Role | +|---|---|---|---|---|---| +| `Font.ttf` | Cantarell Regular | 1.004 | 2048 | 388 | Regular text face | +| `FontBold.ttf` | Cantarell Bold | 1.004 | 2048 | 388 | Bold text face | +| `FontMono.ttf` | JetBrains Mono | 2.304 | 1000 | 1363 | Monospace text face, code | +| `FontCyrillic.ttf` | Cantarell Cyrillic Regular | 0.111 | 1000 | 182 | Cyrillic fallback of the regular face | +| `FontCyrillicBold.ttf` | Cantarell Cyrillic Bold | 0.111 | 1000 | 182 | Cyrillic fallback of the bold face | +| `FontSymbols.ttf` | Noto Sans Symbols 2 | 2.008 | 1000 | 637 | Arrows, shapes, dingbats | +| `FontMath.ttf` | Noto Sans Math | 3.000 | 1000 | 904 | Mathematical operators | +| `FontSpecials.ttf` | Noto Sans | 2.015 | 1000 | 1 | U+FFFD replacement character | +| `Emoji.ttf` | Noto Emoji | 1.05 | 2048 | 887 | Emoji | + +Every text face falls back to the symbol fonts and then to a Cyrillic cut of +Cantarell. Cantarell itself has no Cyrillic in the 1.004 static release the +faces use (the Google Fonts build), and Russian task titles, descriptions and +Jira imports are data, so they used to render as empty boxes. Cantarell 0.111 +does have Cyrillic, so the cuts come from it; its upem and advances differ from +1.004, which does not matter because the two are only mixed inside a fallback +chain and nanovg scales each font by its own upem. Vertical metrics agree +(x-height 0.482 em, cap height 0.694 em), so the two generations sit on one +line. + +`android/assets/rsc` symlinks these files for the Android app; add a symlink +there when you add a font. + +## Regenerating the Cyrillic cuts + +Sources: Cantarell 0.111 `Cantarell-Regular.otf` / `Cantarell-Bold.otf` +(https://download.gnome.org/sources/cantarell/0.111/). + +```sh +# 1. Keep the Cyrillic blocks and №, drop the layout features we do not use. +pyftsubset Cantarell-Regular.otf \ + --unicodes="U+0400-04FF,U+0500-052F,U+1C80-1C88,U+2116" \ + --output-file=Cantarell-Cyrillic.otf --layout-features='' --no-hinting --desubroutinize + +# 2. Cantarell 0.111 is CFF ("Cantarell-Regular.otf"); stb_truetype, which +# nanovg uses, needs TrueType outlines, so convert with FontForge. +fontforge -lang=py -c "import fontforge; f = fontforge.open('Cantarell-Cyrillic.otf'); f.generate('FontCyrillic.ttf')" + +# 3. Rename it (family "Cantarell Cyrillic", PostScript name +# "Cantarell-Cyrillic-Regular") and set OS/2 usWeightClass and head.macStyle +# to 400/700 with fontTools, so the file cannot be confused with the full +# Cantarell and the bold cut is recognisably bold. +``` + +Checking the result: glyph bounding boxes must match the source OTF exactly +(scale by upem), and `stbtt_InitFont` must accept the file and find every +Cyrillic codepoint — `TestGraphicApplication` fails loudly otherwise. diff --git a/Graphics/rsc/Font.ttf b/GraphicApplication/rsc/Font.ttf similarity index 100% rename from Graphics/rsc/Font.ttf rename to GraphicApplication/rsc/Font.ttf diff --git a/GraphicApplication/rsc/FontBold.ttf b/GraphicApplication/rsc/FontBold.ttf new file mode 100644 index 00000000..c7056b1b Binary files /dev/null and b/GraphicApplication/rsc/FontBold.ttf differ diff --git a/GraphicApplication/rsc/FontCyrillic.ttf b/GraphicApplication/rsc/FontCyrillic.ttf new file mode 100644 index 00000000..312e7a9c Binary files /dev/null and b/GraphicApplication/rsc/FontCyrillic.ttf differ diff --git a/GraphicApplication/rsc/FontCyrillicBold.ttf b/GraphicApplication/rsc/FontCyrillicBold.ttf new file mode 100644 index 00000000..45d2577f Binary files /dev/null and b/GraphicApplication/rsc/FontCyrillicBold.ttf differ diff --git a/GraphicApplication/rsc/FontMath.ttf b/GraphicApplication/rsc/FontMath.ttf new file mode 100644 index 00000000..2a5c4d35 Binary files /dev/null and b/GraphicApplication/rsc/FontMath.ttf differ diff --git a/GraphicApplication/rsc/FontMono.ttf b/GraphicApplication/rsc/FontMono.ttf new file mode 100644 index 00000000..dff66cc5 Binary files /dev/null and b/GraphicApplication/rsc/FontMono.ttf differ diff --git a/GraphicApplication/rsc/FontSpecials.ttf b/GraphicApplication/rsc/FontSpecials.ttf new file mode 100644 index 00000000..6e11fb84 Binary files /dev/null and b/GraphicApplication/rsc/FontSpecials.ttf differ diff --git a/GraphicApplication/rsc/FontSymbols.ttf b/GraphicApplication/rsc/FontSymbols.ttf new file mode 100644 index 00000000..239d0cd9 Binary files /dev/null and b/GraphicApplication/rsc/FontSymbols.ttf differ diff --git a/GraphicApplication/rsc/licenses/Cantarell-OFL.txt b/GraphicApplication/rsc/licenses/Cantarell-OFL.txt new file mode 100644 index 00000000..ada24255 --- /dev/null +++ b/GraphicApplication/rsc/licenses/Cantarell-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2009 The Cantarell Project Authors (https://github.com/davelab6/cantarell) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/GraphicApplication/rsc/licenses/JetBrainsMono-OFL.txt b/GraphicApplication/rsc/licenses/JetBrainsMono-OFL.txt new file mode 100644 index 00000000..8bee4148 --- /dev/null +++ b/GraphicApplication/rsc/licenses/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/GraphicApplication/rsc/licenses/Noto-OFL.txt b/GraphicApplication/rsc/licenses/Noto-OFL.txt new file mode 100644 index 00000000..2b3b408a --- /dev/null +++ b/GraphicApplication/rsc/licenses/Noto-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols, https://github.com/notofonts/math, https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/GraphicApplication/src/application/private/GraphicApplication.cpp b/GraphicApplication/src/application/private/GraphicApplication.cpp new file mode 100644 index 00000000..30df86be --- /dev/null +++ b/GraphicApplication/src/application/private/GraphicApplication.cpp @@ -0,0 +1,175 @@ +#include "GraphicApplication.hpp" + +using namespace tp; + +WindowIdentity WindowIdentity::generic() { return { MODULES_GENERIC_APP_ID, MODULES_GENERIC_APP_NAME }; } + +Application::Application(ApplicationDelegate& delegate, const ApplicationOptions& options) : + mDelegate(delegate), + mProcessSection(mProfiler.section("frame.process")), + mPresentSection(mProfiler.section("frame.present")), + mDrawSection(mProfiler.section("frame.draw")), + mInputEventsCounter(mProfiler.counter("input.events")), + mGpu(Window::createGpuDevice()), + mWindow(std::make_unique(*mGpu, mProfiler, options.window)), + mCanvas(std::make_unique(mWindow->surface(), mProfiler, options.resourceDirectory)), + mDebugGUI(std::make_unique(*mWindow, options.resourceDirectory, options.debugInterface)) { + mHangSettings.application = HangWatchdog::processName(); + mPerSecondTimer.setDuration(1000.f); +} + +Application::~Application() { finish(); } + +void Application::setHangSettings(HangSettings settings) { mHangSettings = std::move(settings); } + +void Application::start() { + if (mRunning) { + return; + } + + mHangWatchdog = std::make_unique(mHangSettings, mProfiler.trail()); + for (const HangWatchdog::ReportFileSource& reportFileSource : mHangReportFileSources) { + mHangWatchdog->addReportFileSource(reportFileSource); + } + + mWindow->loop().waitForReady(); + + mPreviousFrameTime = get_time(); + mPerSecondTimer.reset(); + mRunning = true; + runFrame(); +} + +void Application::step(halnf deltaMs) { + if (!mRunning) { + return; + } + + enterPhase(UiPhase::Idle); + mWindow->loop().processEvents(false); + runFrame(deltaMs); +} + +void Application::finish() { + if (!mRunning) { + return; + } + + mHangWatchdog.reset(); + mRunning = false; +} + +void Application::run() { + start(); + + WindowEventLoop& loop = mWindow->loop(); + while (mRunning && !mWindow->controls().shouldClose()) { + enterPhase(UiPhase::Idle); + loop.processEvents(!wantsFrame() || loop.isWaitingForFrame()); + while (loop.isWaitingForFrame() && !mWindow->controls().shouldClose()) { + loop.processEvents(true); + } + runFrame(); + } + + finish(); +} + +void Application::addHangReportFileSource(HangWatchdog::ReportFileSource reportFileSource) { + if (mHangWatchdog) { + mHangWatchdog->addReportFileSource(reportFileSource); + } + mHangReportFileSources.push_back(std::move(reportFileSource)); +} + +void Application::enterPhase(UiPhase phase) { + if (mHangWatchdog) { + mHangWatchdog->enterPhase(phase); + } +} + +bool Application::wantsFrame() { return mDelegate.wantsFrame() || mWindow->loop().hasPendingWork(); } + +void Application::runFrame() { + updateGlobalTime(); + + const time_ms now = get_time(); + const halnf deltaMs = halnf(now - mPreviousFrameTime); + mPreviousFrameTime = now; + runFrame(deltaMs); +} + +void Application::runFrame(halnf deltaMs) { + mProfiler.beginFrame(); + + enterPhase(UiPhase::Input); + processPendingInput(deltaMs); + + enterPhase(UiPhase::Draw); + present(); + updateFrameCounters(); + + mProfiler.endFrame(); +} + +void Application::processPendingInput(halnf deltaMs) { + const ProfileScope scope(mProfiler, mProcessSection); + + const size_t eventCount = mWindow->loop().advanceFrame(); + mProfiler.addCounter(mInputEventsCounter, (double) eventCount); + if (mHangWatchdog) { + mHangWatchdog->noteInputEvents(eventCount); + } + + mDelegate.processFrame(mWindow->input().state(), deltaMs); + mFramesProcessed++; +} + +void Application::present() { + const ProfileScope scope(mProfiler, mPresentSection); + const std::optional frame = mWindow->loop().frameBegin(); + if (!frame) { + return; + } + + mCanvas->drawBegin(*frame, mWindow->frame()); + mDebugGUI->drawBegin(); + + drawFrameContent(); + + enterPhase(UiPhase::Present); + mCanvas->drawEnd(); + mDebugGUI->drawEnd(*frame); + + mWindow->loop().frameEnd(); + mFramesDrawn++; +} + +void Application::drawFrameContent() { + const ProfileScope scope(mProfiler, mDrawSection); + const WindowFrame& frame = mWindow->frame(); + + mDecorations.paintBase(*mCanvas, frame); + { + const Canvas::SavedState saved(*mCanvas); + mDelegate.drawFrame(*mCanvas); + } + mDecorations.paintFrame(*mCanvas, frame, mWindow->controls().isFocused()); +} + +void Application::updateFrameCounters() { + if (!mPerSecondTimer.isTimeout()) { + return; + } + mPerSecondTimer.reset(); + + mFramesProcessedPerSecond = mFramesProcessed; + mFramesDrawnPerSecond = mFramesDrawn; + mFramesDrawn = 0; + mFramesProcessed = 0; +} + +void Application::showFrameRates() { + mDebugGUI->showValue("Frames processed per second", mFramesProcessedPerSecond); + mDebugGUI->showValue("Frames drawn per second", mFramesDrawnPerSecond); +} diff --git a/GraphicApplication/src/application/private/SessionRecording.cpp b/GraphicApplication/src/application/private/SessionRecording.cpp new file mode 100644 index 00000000..cde05da5 --- /dev/null +++ b/GraphicApplication/src/application/private/SessionRecording.cpp @@ -0,0 +1,175 @@ +#include "SessionRecording.hpp" + +#include +#include +#include +#include +#include +#include + +namespace tp { + + namespace { + double steadyMs() { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return std::chrono::duration(now).count(); + } + + bool writeText(const std::filesystem::path& path, const std::string& text) { + std::ofstream file(path, std::ios::binary | std::ios::trunc); + file.write(text.data(), (std::streamsize) text.size()); + return (bool) file; + } + } + + std::string SessionEnvironment::serialize() const { + char size[64]; + std::snprintf(size, sizeof(size), "%.1f %.1f", windowSize.x, windowSize.y); + char scale[32]; + std::snprintf(scale, sizeof(scale), "%.3f", contentScale); + return "application " + application + "\nrevision " + revision + "\ndevice " + device + "\ndate " + date + + "\nwindow " + size + "\nscale " + scale + "\n"; + } + + bool SessionRecording::save(const std::string& directory) const { + const std::filesystem::path root(directory); + std::error_code error; + std::filesystem::create_directories(root, error); + if (error) { + return false; + } + + bool written = writeText(root / "environment.txt", environment.serialize()); + written = written && input.save((root / "input.txt").string()); + if (!recordedFrames.empty()) { + written = written && writeText(root / "recorded-frames.tsv", recordedFrames); + } + if (saveSnapshot) { + written = written && saveSnapshot(directory); + } + return written; + } + + struct SessionRecorder::Recording { + explicit Recording(InputRecorder::Clock clock) : + input(std::move(clock)) {} + + SessionEnvironment environment; + SessionRecording::Saver snapshot; + InputRecorder input; + double startMs = 0; + bool profilerWasEnabled = false; + }; + + SessionRecorder::SessionRecorder(Application& application, Clock clock) : + mApplication(application), + mClock(clock ? std::move(clock) : Clock(steadyMs)) {} + + SessionRecorder::~SessionRecorder() { cancel(); } + + double SessionRecorder::elapsedMs() const { return mRecording ? mClock() - mRecording->startMs : 0.0; } + + SessionEnvironment SessionRecorder::environment() const { + SessionEnvironment environment; + environment.application = HangWatchdog::processName(); + environment.revision = mRevision; + environment.device = mApplication.gpu().deviceName(); + + const std::time_t now = std::time(nullptr); + char date[64]; + std::strftime(date, sizeof(date), "%Y-%m-%d %H:%M:%S", std::localtime(&now)); + environment.date = date; + + environment.windowSize = mApplication.window().contentSize(); + environment.contentScale = mApplication.window().contentScale(); + return environment; + } + + void SessionRecorder::start() { + if (mRecording) { + return; + } + + const SessionEnvironment recorded = environment(); + mRecording = std::make_unique(mClock); + mRecording->startMs = mClock(); + mRecording->environment = recorded; + mRecording->snapshot = mSnapshot ? mSnapshot(recorded) : SessionRecording::Saver(); + mRecording->input.setWindowSize(recorded.windowSize); + mRecording->input.startClockNow(); + + Profiler& profiler = mApplication.profiler(); + mRecording->profilerWasEnabled = profiler.isEnabled(); + profiler.clear(); + profiler.setEnabled(true); + mApplication.window().input().setRawInputTap(&mRecording->input); + } + + SessionRecording SessionRecorder::stop() { + SessionRecording session; + if (!mRecording) { + return session; + } + + mApplication.window().input().setRawInputTap(nullptr); + session.environment = std::move(mRecording->environment); + session.input = mRecording->input.recording(); + session.recordedFrames = recordedFramesTsv(mApplication.profiler()); + session.saveSnapshot = std::move(mRecording->snapshot); + restoreProfiler(); + return session; + } + + void SessionRecorder::cancel() { + if (!mRecording) { + return; + } + mApplication.window().input().setRawInputTap(nullptr); + restoreProfiler(); + } + + void SessionRecorder::restoreProfiler() { + Profiler& profiler = mApplication.profiler(); + profiler.setEnabled(mRecording->profilerWasEnabled); + profiler.clear(); + mRecording.reset(); + } + + std::string SessionRecorder::defaultDirectory(const std::string& applicationSessionsDirectory) { + const char* configured = std::getenv("MODULES_SESSION_DIR"); + if (configured && *configured) { + return configured; + } + return applicationSessionsDirectory; + } + + std::string SessionRecorder::recordedFramesTsv(const Profiler& profiler) { + std::string text = "frame\twall_ms\tcpu_ms"; + for (const std::string& name : profiler.sectionNames()) { + text += "\t" + name; + } + for (const std::string& name : profiler.counterNames()) { + text += "\t" + name; + } + text += "\n"; + + char value[32]; + auto append = [&](double number) { + std::snprintf(value, sizeof(value), "\t%.4f", number); + text += value; + }; + for (const FrameProfile& frame : profiler.frames()) { + text += std::to_string(frame.index); + append(frame.wallMs); + append(frame.cpuMs); + for (size_t index = 0; index < profiler.sectionNames().size(); index++) { + append(index < frame.sectionMs.size() ? frame.sectionMs[index] : 0.0); + } + for (size_t index = 0; index < profiler.counterNames().size(); index++) { + append(index < frame.counters.size() ? frame.counters[index] : 0.0); + } + text += "\n"; + } + return text; + } +} diff --git a/GraphicApplication/src/application/public/GraphicApplication.hpp b/GraphicApplication/src/application/public/GraphicApplication.hpp new file mode 100644 index 00000000..8a6db53a --- /dev/null +++ b/GraphicApplication/src/application/public/GraphicApplication.hpp @@ -0,0 +1,97 @@ +#pragma once + +#include "Canvas.hpp" +#include "DebugGUI.hpp" +#include "HangWatchdog.hpp" +#include "Profiler.hpp" +#include "Timing.hpp" +#include "Window.hpp" +#include "WindowDecorations.hpp" + +#include +#include +#include + +namespace tp { + + struct ApplicationOptions { + WindowOptions window = { .identity = WindowIdentity::generic() }; + std::filesystem::path resourceDirectory = Window::platformResourceDirectory(); + DebugInterface debugInterface = DebugInterface::Visible; + }; + + // What an application does each frame. Application drives the frame loop and calls it. + class ApplicationDelegate { + public: + virtual ~ApplicationDelegate() = default; + + [[nodiscard]] virtual bool wantsFrame() { return false; } + virtual void processFrame(const InputState& input, halnf deltaMs) {} + virtual void drawFrame(Canvas& canvas) {} + }; + + class Application { + public: + explicit Application(ApplicationDelegate& delegate, const ApplicationOptions& options = {}); + ~Application(); + + Application(const Application&) = delete; + Application& operator=(const Application&) = delete; + + void run(); + void start(); + void step(halnf deltaMs); + void finish(); + [[nodiscard]] bool isRunning() const { return mRunning; } + + [[nodiscard]] Window& window() { return *mWindow; } + [[nodiscard]] Canvas& canvas() { return *mCanvas; } + [[nodiscard]] DebugGUI& debugGUI() { return *mDebugGUI; } + [[nodiscard]] Profiler& profiler() { return mProfiler; } + [[nodiscard]] GpuDevice& gpu() { return *mGpu; } + + void setHangSettings(HangSettings settings); + void addHangReportFileSource(HangWatchdog::ReportFileSource reportFileSource); + [[nodiscard]] HangWatchdog* hangWatchdog() { return mHangWatchdog.get(); } + + [[nodiscard]] halnf framesProcessedPerSecond() const { return mFramesProcessedPerSecond; } + [[nodiscard]] halnf framesDrawnPerSecond() const { return mFramesDrawnPerSecond; } + void showFrameRates(); + + private: + void enterPhase(UiPhase phase); + [[nodiscard]] bool wantsFrame(); + void runFrame(); + void runFrame(halnf deltaMs); + void processPendingInput(halnf deltaMs); + void present(); + void drawFrameContent(); + void updateFrameCounters(); + + ApplicationDelegate& mDelegate; + Profiler mProfiler; + HangSettings mHangSettings; + + bool mRunning = false; + time_ms mPreviousFrameTime = 0; + std::unique_ptr mHangWatchdog; + std::vector mHangReportFileSources; + + Timer mPerSecondTimer; + halnf mFramesProcessedPerSecond = 0; + halnf mFramesDrawnPerSecond = 0; + halnf mFramesProcessed = 0; + halnf mFramesDrawn = 0; + + ProfileId mProcessSection; + ProfileId mPresentSection; + ProfileId mDrawSection; + ProfileId mInputEventsCounter; + + WindowDecorations mDecorations; + std::unique_ptr mGpu; + std::unique_ptr mWindow; + std::unique_ptr mCanvas; + std::unique_ptr mDebugGUI; + }; +} diff --git a/GraphicApplication/src/application/public/SessionRecording.hpp b/GraphicApplication/src/application/public/SessionRecording.hpp new file mode 100644 index 00000000..57e5a85a --- /dev/null +++ b/GraphicApplication/src/application/public/SessionRecording.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "GraphicApplication.hpp" +#include "InputRecording.hpp" + +#include +#include +#include + +namespace tp { + + struct SessionEnvironment { + std::string revision; + std::string device; + std::string date; + Vec2F windowSize = { 0, 0 }; + halnf contentScale = 1; + std::string application; + + [[nodiscard]] std::string serialize() const; + }; + + // A recorded session: the environment, every raw input event and the profiled frames, saved as one directory. + struct SessionRecording { + using Saver = std::function; + + SessionEnvironment environment; + InputRecording input; + std::string recordedFrames; + Saver saveSnapshot; + + bool save(const std::string& directory) const; + }; + + // Records an application's raw input and frame profile between start() and stop(), for bug reports and replays. + class SessionRecorder { + public: + using Snapshot = std::function; + using Clock = std::function; + + explicit SessionRecorder(Application& application, Clock clock = {}); + ~SessionRecorder(); + + SessionRecorder(const SessionRecorder&) = delete; + SessionRecorder& operator=(const SessionRecorder&) = delete; + + void setSnapshot(Snapshot snapshot) { mSnapshot = std::move(snapshot); } + void setRevision(std::string revision) { mRevision = std::move(revision); } + + [[nodiscard]] bool isRecording() const { return mRecording != nullptr; } + [[nodiscard]] double elapsedMs() const; + + void start(); + [[nodiscard]] SessionRecording stop(); + void cancel(); + + [[nodiscard]] SessionEnvironment environment() const; + + [[nodiscard]] static std::string defaultDirectory(const std::string& applicationSessionsDirectory); + [[nodiscard]] static std::string recordedFramesTsv(const Profiler& profiler); + + private: + struct Recording; + + void restoreProfiler(); + + Application& mApplication; + Clock mClock; + std::string mRevision = "dev"; + Snapshot mSnapshot; + std::unique_ptr mRecording; + }; +} diff --git a/GraphicApplication/src/backends/android/private/AndroidEntry.cpp b/GraphicApplication/src/backends/android/private/AndroidEntry.cpp new file mode 100644 index 00000000..9783d4ae --- /dev/null +++ b/GraphicApplication/src/backends/android/private/AndroidEntry.cpp @@ -0,0 +1,45 @@ +#include "AndroidEntry.hpp" + +#include "AndroidPlatform.hpp" + +#include +#include +#include + +#include +#include + +namespace tp { + + namespace { + void drainUntilDestroyed(android_app* app) { + while (!app->destroyRequested) { + int events = 0; + android_poll_source* source = nullptr; + if (ALooper_pollOnce(-1, nullptr, &events, (void**) &source) >= 0 && source) { + source->process(app, source); + } + } + } + } + + void runAndroidApplication( + android_app* app, AndroidMain main, const char* const* assetDirectories, int assetDirectoryCount + ) { + for (int index = 0; index < assetDirectoryCount; index++) { + android::extractAssets(*app, assetDirectories[index]); + } + setenv("HOME", app->activity->internalDataPath, 1); + + try { + main(app); + } catch (const std::exception& error) { + __android_log_print(ANDROID_LOG_FATAL, "modules", "application failed: %s", error.what()); + } + + if (!app->destroyRequested) { + ANativeActivity_finish(app->activity); + } + drainUntilDestroyed(app); + } +} diff --git a/GraphicApplication/src/backends/android/private/AndroidKeys.cpp b/GraphicApplication/src/backends/android/private/AndroidKeys.cpp new file mode 100644 index 00000000..c15c4bd3 --- /dev/null +++ b/GraphicApplication/src/backends/android/private/AndroidKeys.cpp @@ -0,0 +1,130 @@ +#include "AndroidKeys.hpp" + +#include + +#include + +namespace tp::android { + + namespace { + const std::unordered_map& keyCodes() { + static const std::unordered_map keys = { + { AKEYCODE_SPACE, Key::Space }, + { AKEYCODE_APOSTROPHE, Key::Apostrophe }, + { AKEYCODE_COMMA, Key::Comma }, + { AKEYCODE_MINUS, Key::Minus }, + { AKEYCODE_PERIOD, Key::Period }, + { AKEYCODE_SLASH, Key::Slash }, + { AKEYCODE_0, Key::Digit0 }, + { AKEYCODE_1, Key::Digit1 }, + { AKEYCODE_2, Key::Digit2 }, + { AKEYCODE_3, Key::Digit3 }, + { AKEYCODE_4, Key::Digit4 }, + { AKEYCODE_5, Key::Digit5 }, + { AKEYCODE_6, Key::Digit6 }, + { AKEYCODE_7, Key::Digit7 }, + { AKEYCODE_8, Key::Digit8 }, + { AKEYCODE_9, Key::Digit9 }, + { AKEYCODE_SEMICOLON, Key::Semicolon }, + { AKEYCODE_EQUALS, Key::Equal }, + { AKEYCODE_A, Key::A }, + { AKEYCODE_B, Key::B }, + { AKEYCODE_C, Key::C }, + { AKEYCODE_D, Key::D }, + { AKEYCODE_E, Key::E }, + { AKEYCODE_F, Key::F }, + { AKEYCODE_G, Key::G }, + { AKEYCODE_H, Key::H }, + { AKEYCODE_I, Key::I }, + { AKEYCODE_J, Key::J }, + { AKEYCODE_K, Key::K }, + { AKEYCODE_L, Key::L }, + { AKEYCODE_M, Key::M }, + { AKEYCODE_N, Key::N }, + { AKEYCODE_O, Key::O }, + { AKEYCODE_P, Key::P }, + { AKEYCODE_Q, Key::Q }, + { AKEYCODE_R, Key::R }, + { AKEYCODE_S, Key::S }, + { AKEYCODE_T, Key::T }, + { AKEYCODE_U, Key::U }, + { AKEYCODE_V, Key::V }, + { AKEYCODE_W, Key::W }, + { AKEYCODE_X, Key::X }, + { AKEYCODE_Y, Key::Y }, + { AKEYCODE_Z, Key::Z }, + { AKEYCODE_LEFT_BRACKET, Key::LeftBracket }, + { AKEYCODE_BACKSLASH, Key::Backslash }, + { AKEYCODE_RIGHT_BRACKET, Key::RightBracket }, + { AKEYCODE_GRAVE, Key::GraveAccent }, + { AKEYCODE_ESCAPE, Key::Escape }, + { AKEYCODE_ENTER, Key::Enter }, + { AKEYCODE_TAB, Key::Tab }, + { AKEYCODE_DEL, Key::Backspace }, + { AKEYCODE_INSERT, Key::Insert }, + { AKEYCODE_FORWARD_DEL, Key::Delete }, + { AKEYCODE_DPAD_RIGHT, Key::Right }, + { AKEYCODE_DPAD_LEFT, Key::Left }, + { AKEYCODE_DPAD_DOWN, Key::Down }, + { AKEYCODE_DPAD_UP, Key::Up }, + { AKEYCODE_PAGE_UP, Key::PageUp }, + { AKEYCODE_PAGE_DOWN, Key::PageDown }, + { AKEYCODE_MOVE_HOME, Key::Home }, + { AKEYCODE_MOVE_END, Key::End }, + { AKEYCODE_CAPS_LOCK, Key::CapsLock }, + { AKEYCODE_SCROLL_LOCK, Key::ScrollLock }, + { AKEYCODE_NUM_LOCK, Key::NumLock }, + { AKEYCODE_SYSRQ, Key::PrintScreen }, + { AKEYCODE_BREAK, Key::Pause }, + { AKEYCODE_F1, Key::F1 }, + { AKEYCODE_F2, Key::F2 }, + { AKEYCODE_F3, Key::F3 }, + { AKEYCODE_F4, Key::F4 }, + { AKEYCODE_F5, Key::F5 }, + { AKEYCODE_F6, Key::F6 }, + { AKEYCODE_F7, Key::F7 }, + { AKEYCODE_F8, Key::F8 }, + { AKEYCODE_F9, Key::F9 }, + { AKEYCODE_F10, Key::F10 }, + { AKEYCODE_F11, Key::F11 }, + { AKEYCODE_F12, Key::F12 }, + { AKEYCODE_NUMPAD_0, Key::Keypad0 }, + { AKEYCODE_NUMPAD_1, Key::Keypad1 }, + { AKEYCODE_NUMPAD_2, Key::Keypad2 }, + { AKEYCODE_NUMPAD_3, Key::Keypad3 }, + { AKEYCODE_NUMPAD_4, Key::Keypad4 }, + { AKEYCODE_NUMPAD_5, Key::Keypad5 }, + { AKEYCODE_NUMPAD_6, Key::Keypad6 }, + { AKEYCODE_NUMPAD_7, Key::Keypad7 }, + { AKEYCODE_NUMPAD_8, Key::Keypad8 }, + { AKEYCODE_NUMPAD_9, Key::Keypad9 }, + { AKEYCODE_NUMPAD_DOT, Key::KeypadDecimal }, + { AKEYCODE_NUMPAD_DIVIDE, Key::KeypadDivide }, + { AKEYCODE_NUMPAD_MULTIPLY, Key::KeypadMultiply }, + { AKEYCODE_NUMPAD_SUBTRACT, Key::KeypadSubtract }, + { AKEYCODE_NUMPAD_ADD, Key::KeypadAdd }, + { AKEYCODE_NUMPAD_ENTER, Key::KeypadEnter }, + { AKEYCODE_NUMPAD_EQUALS, Key::KeypadEqual }, + { AKEYCODE_SHIFT_LEFT, Key::LeftShift }, + { AKEYCODE_CTRL_LEFT, Key::LeftControl }, + { AKEYCODE_ALT_LEFT, Key::LeftAlt }, + { AKEYCODE_META_LEFT, Key::LeftSuper }, + { AKEYCODE_SHIFT_RIGHT, Key::RightShift }, + { AKEYCODE_CTRL_RIGHT, Key::RightControl }, + { AKEYCODE_ALT_RIGHT, Key::RightAlt }, + { AKEYCODE_META_RIGHT, Key::RightSuper }, + { AKEYCODE_MENU, Key::Menu }, + }; + return keys; + } + } + + std::optional keyFromAndroid(int32_t keyCode) { + const auto& keys = keyCodes(); + const auto found = keys.find(keyCode); + if (found == keys.end()) { + return std::nullopt; + } + return found->second; + } +} diff --git a/GraphicApplication/src/backends/android/private/AndroidKeys.hpp b/GraphicApplication/src/backends/android/private/AndroidKeys.hpp new file mode 100644 index 00000000..89952ab6 --- /dev/null +++ b/GraphicApplication/src/backends/android/private/AndroidKeys.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include "InputCodes.hpp" + +#include +#include + +namespace tp::android { + + [[nodiscard]] std::optional keyFromAndroid(int32_t keyCode); +} diff --git a/GraphicApplication/src/backends/android/private/AndroidPlatform.cpp b/GraphicApplication/src/backends/android/private/AndroidPlatform.cpp new file mode 100644 index 00000000..625a961a --- /dev/null +++ b/GraphicApplication/src/backends/android/private/AndroidPlatform.cpp @@ -0,0 +1,301 @@ +#include "AndroidPlatform.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace tp::android { + + namespace { + + struct AttachedEnv { + explicit AttachedEnv(ANativeActivity* activity) : + vm(activity->vm) { + if (vm->GetEnv((void**) &env, JNI_VERSION_1_6) == JNI_EDETACHED) { + vm->AttachCurrentThread(&env, nullptr); + attached = true; + } + } + + ~AttachedEnv() { + if (attached) { + vm->DetachCurrentThread(); + } + } + + JavaVM* vm; + JNIEnv* env = nullptr; + bool attached = false; + }; + + void appendUtf8(std::string& out, uint32_t codepoint) { + if (codepoint < 0x80) { + out += (char) codepoint; + } else if (codepoint < 0x800) { + out += (char) (0xC0 | (codepoint >> 6)); + out += (char) (0x80 | (codepoint & 0x3F)); + } else if (codepoint < 0x10000) { + out += (char) (0xE0 | (codepoint >> 12)); + out += (char) (0x80 | ((codepoint >> 6) & 0x3F)); + out += (char) (0x80 | (codepoint & 0x3F)); + } else { + out += (char) (0xF0 | (codepoint >> 18)); + out += (char) (0x80 | ((codepoint >> 12) & 0x3F)); + out += (char) (0x80 | ((codepoint >> 6) & 0x3F)); + out += (char) (0x80 | (codepoint & 0x3F)); + } + } + } + + std::string unicodeText(android_app& app, const AInputEvent* event) { + AttachedEnv attached(app.activity); + JNIEnv* env = attached.env; + if (!env) { + return {}; + } + + jclass keyEventClass = env->FindClass("android/view/KeyEvent"); + jmethodID constructor = env->GetMethodID(keyEventClass, "", "(JJIIIIIIII)V"); + jmethodID getUnicodeChar = env->GetMethodID(keyEventClass, "getUnicodeChar", "(I)I"); + jobject keyEvent = env->NewObject( + keyEventClass, + constructor, + (jlong) AKeyEvent_getDownTime(event), + (jlong) AKeyEvent_getEventTime(event), + (jint) AKeyEvent_getAction(event), + (jint) AKeyEvent_getKeyCode(event), + (jint) AKeyEvent_getRepeatCount(event), + (jint) AKeyEvent_getMetaState(event), + (jint) AInputEvent_getDeviceId(event), + (jint) AKeyEvent_getScanCode(event), + (jint) AKeyEvent_getFlags(event), + (jint) AInputEvent_getSource(event) + ); + const jint codepoint = env->CallIntMethod(keyEvent, getUnicodeChar, (jint) AKeyEvent_getMetaState(event)); + env->DeleteLocalRef(keyEvent); + env->DeleteLocalRef(keyEventClass); + + std::string text; + if (codepoint > 0 && codepoint >= 0x20 && codepoint != 0x7F) { + appendUtf8(text, (uint32_t) codepoint); + } + return text; + } + + void setSoftKeyboardVisible(android_app& app, bool visible) { + AttachedEnv attached(app.activity); + JNIEnv* env = attached.env; + if (!env) { + return; + } + + jobject activity = app.activity->clazz; + jclass activityClass = env->GetObjectClass(activity); + jclass contextClass = env->FindClass("android/content/Context"); + jfieldID serviceField = env->GetStaticFieldID(contextClass, "INPUT_METHOD_SERVICE", "Ljava/lang/String;"); + jobject serviceName = env->GetStaticObjectField(contextClass, serviceField); + jmethodID getSystemService = + env->GetMethodID(activityClass, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;"); + jobject inputManager = env->CallObjectMethod(activity, getSystemService, serviceName); + + jmethodID getWindow = env->GetMethodID(activityClass, "getWindow", "()Landroid/view/Window;"); + jobject window = env->CallObjectMethod(activity, getWindow); + jclass windowClass = env->FindClass("android/view/Window"); + jmethodID getDecorView = env->GetMethodID(windowClass, "getDecorView", "()Landroid/view/View;"); + jobject decorView = env->CallObjectMethod(window, getDecorView); + + jclass managerClass = env->FindClass("android/view/inputmethod/InputMethodManager"); + if (visible) { + jmethodID show = env->GetMethodID(managerClass, "showSoftInput", "(Landroid/view/View;I)Z"); + env->CallBooleanMethod(inputManager, show, decorView, 0); + } else { + jclass viewClass = env->FindClass("android/view/View"); + jmethodID getToken = env->GetMethodID(viewClass, "getWindowToken", "()Landroid/os/IBinder;"); + jobject token = env->CallObjectMethod(decorView, getToken); + jmethodID hide = env->GetMethodID(managerClass, "hideSoftInputFromWindow", "(Landroid/os/IBinder;I)Z"); + env->CallBooleanMethod(inputManager, hide, token, 0); + env->DeleteLocalRef(token); + env->DeleteLocalRef(viewClass); + } + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + + env->DeleteLocalRef(managerClass); + env->DeleteLocalRef(decorView); + env->DeleteLocalRef(windowClass); + env->DeleteLocalRef(window); + env->DeleteLocalRef(inputManager); + env->DeleteLocalRef(serviceName); + env->DeleteLocalRef(contextClass); + env->DeleteLocalRef(activityClass); + } + + namespace { + struct InsetEdges { + jobject bars = nullptr; + jobject gestures = nullptr; + }; + + InsetEdges typedInsetEdges(JNIEnv* env, jobject insets, jclass insetsClass) { + jclass typeClass = env->FindClass("android/view/WindowInsets$Type"); + const auto typeMask = [&](const char* name) { + return env->CallStaticIntMethod(typeClass, env->GetStaticMethodID(typeClass, name, "()I")); + }; + const jint bars = typeMask("systemBars") | typeMask("displayCutout"); + const jint gestures = typeMask("mandatorySystemGestures"); + env->DeleteLocalRef(typeClass); + + jmethodID getInsetsOf = env->GetMethodID(insetsClass, "getInsets", "(I)Landroid/graphics/Insets;"); + return { env->CallObjectMethod(insets, getInsetsOf, bars), env->CallObjectMethod(insets, getInsetsOf, gestures) }; + } + + InsetEdges legacyInsetEdges(JNIEnv* env, jobject insets, jclass insetsClass) { + const auto edgesOf = [&](const char* getter) { + return env->CallObjectMethod(insets, env->GetMethodID(insetsClass, getter, "()Landroid/graphics/Insets;")); + }; + return { edgesOf("getSystemWindowInsets"), edgesOf("getMandatorySystemGestureInsets") }; + } + } + + EdgeInsets systemInsets(android_app& app) { + AttachedEnv attached(app.activity); + JNIEnv* env = attached.env; + if (!env) { + return {}; + } + + EdgeInsets result; + jobject activity = app.activity->clazz; + jclass activityClass = env->GetObjectClass(activity); + jmethodID getWindow = env->GetMethodID(activityClass, "getWindow", "()Landroid/view/Window;"); + jobject window = env->CallObjectMethod(activity, getWindow); + jclass windowClass = env->FindClass("android/view/Window"); + jmethodID getDecorView = env->GetMethodID(windowClass, "getDecorView", "()Landroid/view/View;"); + jobject decorView = window ? env->CallObjectMethod(window, getDecorView) : nullptr; + jclass viewClass = env->FindClass("android/view/View"); + jmethodID getInsets = env->GetMethodID(viewClass, "getRootWindowInsets", "()Landroid/view/WindowInsets;"); + jobject insets = decorView ? env->CallObjectMethod(decorView, getInsets) : nullptr; + + if (insets) { + jclass insetsClass = env->FindClass("android/view/WindowInsets"); + const InsetEdges sources = android_get_device_api_level() >= 30 ? typedInsetEdges(env, insets, insetsClass) + : legacyInsetEdges(env, insets, insetsClass); + jclass edgesClass = env->FindClass("android/graphics/Insets"); + const auto edge = [&](jobject edges, const char* name) { + return edges ? (halnf) env->GetIntField(edges, env->GetFieldID(edgesClass, name, "I")) : 0.0f; + }; + const auto both = [&](const char* name) { + return std::max(edge(sources.bars, name), edge(sources.gestures, name)); + }; + result = { both("left"), both("top"), both("right"), both("bottom") }; + + env->DeleteLocalRef(edgesClass); + if (sources.gestures) { + env->DeleteLocalRef(sources.gestures); + } + if (sources.bars) { + env->DeleteLocalRef(sources.bars); + } + env->DeleteLocalRef(insetsClass); + env->DeleteLocalRef(insets); + } + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + + env->DeleteLocalRef(viewClass); + if (decorView) { + env->DeleteLocalRef(decorView); + } + env->DeleteLocalRef(windowClass); + if (window) { + env->DeleteLocalRef(window); + } + env->DeleteLocalRef(activityClass); + return result; + } + + bool openUrl(android_app& app, const std::string& url) { + AttachedEnv attached(app.activity); + JNIEnv* env = attached.env; + if (!env || url.empty()) { + return false; + } + + jclass uriClass = env->FindClass("android/net/Uri"); + jmethodID parse = env->GetStaticMethodID(uriClass, "parse", "(Ljava/lang/String;)Landroid/net/Uri;"); + jstring text = env->NewStringUTF(url.c_str()); + jobject uri = env->CallStaticObjectMethod(uriClass, parse, text); + + jclass intentClass = env->FindClass("android/content/Intent"); + jmethodID constructor = env->GetMethodID(intentClass, "", "(Ljava/lang/String;Landroid/net/Uri;)V"); + jstring action = env->NewStringUTF("android.intent.action.VIEW"); + jobject intent = env->NewObject(intentClass, constructor, action, uri); + jmethodID addFlags = env->GetMethodID(intentClass, "addFlags", "(I)Landroid/content/Intent;"); + constexpr jint kNewTask = 0x10000000; + env->DeleteLocalRef(env->CallObjectMethod(intent, addFlags, kNewTask)); + + jobject activity = app.activity->clazz; + jclass activityClass = env->GetObjectClass(activity); + jmethodID startActivity = env->GetMethodID(activityClass, "startActivity", "(Landroid/content/Intent;)V"); + env->CallVoidMethod(activity, startActivity, intent); + const bool failed = env->ExceptionCheck(); + if (failed) { + env->ExceptionClear(); + } + + env->DeleteLocalRef(activityClass); + env->DeleteLocalRef(intent); + env->DeleteLocalRef(action); + env->DeleteLocalRef(intentClass); + if (uri) { + env->DeleteLocalRef(uri); + } + env->DeleteLocalRef(text); + env->DeleteLocalRef(uriClass); + return !failed; + } + + namespace { + void + extractDirectory(AAssetManager* manager, const std::string& assetDirectory, const std::filesystem::path& target) { + AAssetDir* directory = AAssetManager_openDir(manager, assetDirectory.c_str()); + if (!directory) { + return; + } + std::filesystem::create_directories(target); + while (const char* name = AAssetDir_getNextFileName(directory)) { + const std::string assetPath = assetDirectory.empty() ? name : assetDirectory + "/" + name; + AAsset* asset = AAssetManager_open(manager, assetPath.c_str(), AASSET_MODE_STREAMING); + if (!asset) { + continue; + } + std::ofstream out(target / name, std::ios::binary | std::ios::trunc); + std::vector buffer(64 * 1024); + int read = 0; + while ((read = AAsset_read(asset, buffer.data(), buffer.size())) > 0) { + out.write(buffer.data(), read); + } + AAsset_close(asset); + } + AAssetDir_close(directory); + } + } + + void extractAssets(android_app& app, const char* directory) { + AAssetManager* manager = app.activity->assetManager; + const std::filesystem::path target = std::filesystem::path(app.activity->internalDataPath) / directory; + extractDirectory(manager, directory, target); + __android_log_print(ANDROID_LOG_INFO, "modules", "assets extracted to %s", target.c_str()); + } +} diff --git a/GraphicApplication/src/backends/android/private/AndroidPlatform.hpp b/GraphicApplication/src/backends/android/private/AndroidPlatform.hpp new file mode 100644 index 00000000..b54efa47 --- /dev/null +++ b/GraphicApplication/src/backends/android/private/AndroidPlatform.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "PlatformInfo.hpp" + +#include + +struct android_app; +struct AInputEvent; + +namespace tp::android { + + [[nodiscard]] std::string unicodeText(android_app& app, const AInputEvent* event); + void setSoftKeyboardVisible(android_app& app, bool visible); + [[nodiscard]] EdgeInsets systemInsets(android_app& app); + bool openUrl(android_app& app, const std::string& url); + + void extractAssets(android_app& app, const char* directory); +} diff --git a/GraphicApplication/src/backends/android/private/AndroidWindow.cpp b/GraphicApplication/src/backends/android/private/AndroidWindow.cpp new file mode 100644 index 00000000..eadc73ab --- /dev/null +++ b/GraphicApplication/src/backends/android/private/AndroidWindow.cpp @@ -0,0 +1,505 @@ +#include "AndroidWindow.hpp" + +#include "AndroidKeys.hpp" +#include "AndroidPlatform.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace tp { + + namespace { + constexpr const char* kTag = "modules"; + constexpr halnf kReferenceDensity = 160.0f; + constexpr halnf kWheelStep = 1.0f; + + PointerDevice deviceFor(int32_t type) { + switch (type) { + case AMOTION_EVENT_TOOL_TYPE_STYLUS: + case AMOTION_EVENT_TOOL_TYPE_ERASER: return PointerDevice::Stylus; + case AMOTION_EVENT_TOOL_TYPE_FINGER: return PointerDevice::Finger; + default: return PointerDevice::Mouse; + } + } + + StylusTip tipFor(int32_t type) { + return type == AMOTION_EVENT_TOOL_TYPE_ERASER ? StylusTip::Eraser : StylusTip::Pen; + } + + std::optional buttonFor(int32_t androidButton) { + switch (androidButton) { + case AMOTION_EVENT_BUTTON_PRIMARY: return PointerButton::Left; + case AMOTION_EVENT_BUTTON_SECONDARY: return PointerButton::Right; + case AMOTION_EVENT_BUTTON_TERTIARY: return PointerButton::Middle; + case AMOTION_EVENT_BUTTON_BACK: return PointerButton::Back; + case AMOTION_EVENT_BUTTON_FORWARD: return PointerButton::Forward; + default: return std::nullopt; + } + } + } + + AndroidWindow::AndroidWindow(PlatformWindowHost& host, android_app& app) : + mHost(host), + mApp(app) { + mFrame.setTranslucent(false); + mFrame.setTiled(true); + + mApp.userData = this; + mApp.onAppCmd = onCommand; + mApp.onInputEvent = onInput; + + if (mApp.config) { + const int32_t density = AConfiguration_getDensity(mApp.config); + if (density > 0 && density != ACONFIGURATION_DENSITY_ANY) { + mScale = (halnf) density / kReferenceDensity; + } + } + + while (!mApp.window && !mApp.destroyRequested) { + pollOnce(-1); + } + if (mApp.window) { + attachWindow(mApp.window); + } + } + + AndroidWindow::~AndroidWindow() { + mApp.onAppCmd = nullptr; + mApp.onInputEvent = nullptr; + mApp.userData = nullptr; + } + + VkSurfaceKHR AndroidWindow::createSurface(VkInstance instance) const { + if (!mNativeWindow) { + return VK_NULL_HANDLE; + } + VkAndroidSurfaceCreateInfoKHR info{ + .sType = VK_STRUCTURE_TYPE_ANDROID_SURFACE_CREATE_INFO_KHR, + .window = mNativeWindow, + }; + VkSurfaceKHR surface = VK_NULL_HANDLE; + if (vkCreateAndroidSurfaceKHR(instance, &info, nullptr, &surface) != VK_SUCCESS) { + return VK_NULL_HANDLE; + } + return surface; + } + + VkExtent2D AndroidWindow::framebufferExtent() const { + if (!mNativeWindow) { + return { 0, 0 }; + } + return { (uint32_t) ANativeWindow_getWidth(mNativeWindow), (uint32_t) ANativeWindow_getHeight(mNativeWindow) }; + } + + void AndroidWindow::onTranslucencyResolved(bool) { mFrame.setTranslucent(false); } + + void AndroidWindow::updateGeometry() { + if (!mNativeWindow) { + return; + } + const halnf width = (halnf) ANativeWindow_getWidth(mNativeWindow) / mScale; + const halnf height = (halnf) ANativeWindow_getHeight(mNativeWindow) / mScale; + if (width <= 0 || height <= 0) { + return; + } + + const EdgeInsets pixels = android::systemInsets(mApp); + const EdgeInsets insets = { + pixels.left / mScale, pixels.top / mScale, pixels.right / mScale, pixels.bottom / mScale + }; + const bool sizeChanged = Vec2F(width, height) != mFrame.contentSize(); + if (!sizeChanged && insets == mSafeInsets) { + return; + } + + mSafeInsets = insets; + mFrame.setContentSize({ width, height }); + if (mConfigured) { + mHost.onContentResized(); + } + } + + void AndroidWindow::attachWindow(ANativeWindow* window) { + mNativeWindow = window; + updateGeometry(); + mHost.onSurfaceAvailable(); + __android_log_print( + ANDROID_LOG_INFO, + kTag, + "window %dx%d scale %.2f", + ANativeWindow_getWidth(window), + ANativeWindow_getHeight(window), + mScale + ); + } + + void AndroidWindow::detachWindow() { + mHost.onSurfaceLost(); + mNativeWindow = nullptr; + } + + void AndroidWindow::onCommand(android_app* app, int32_t command) { + if (auto* self = static_cast(app->userData)) { + self->handleCommand(command); + } + } + + int32_t AndroidWindow::onInput(android_app* app, AInputEvent* event) { + auto* self = static_cast(app->userData); + return self && self->handleInput(event) ? 1 : 0; + } + + void AndroidWindow::handleCommand(int32_t command) { + switch (command) { + case APP_CMD_INIT_WINDOW: + if (mApp.window) { + attachWindow(mApp.window); + } + break; + case APP_CMD_TERM_WINDOW: detachWindow(); break; + case APP_CMD_WINDOW_RESIZED: + case APP_CMD_CONFIG_CHANGED: + case APP_CMD_CONTENT_RECT_CHANGED: updateGeometry(); break; + case APP_CMD_GAINED_FOCUS: + mFocused = true; + input().post(KeyboardFocusChanged{ true }); + break; + case APP_CMD_LOST_FOCUS: + mFocused = false; + input().post(KeyboardFocusChanged{ false }); + break; + default: break; + } + } + + bool AndroidWindow::handleInput(AInputEvent* event) { + switch (AInputEvent_getType(event)) { + case AINPUT_EVENT_TYPE_MOTION: return handleMotion(event); + case AINPUT_EVENT_TYPE_KEY: return handleKey(event); + default: return false; + } + } + + bool AndroidWindow::handleMotion(AInputEvent* event) { + const int32_t action = AMotionEvent_getAction(event) & AMOTION_EVENT_ACTION_MASK; + const int32_t toolType = AMotionEvent_getToolType(event, 0); + if (toolType == AMOTION_EVENT_TOOL_TYPE_STYLUS || toolType == AMOTION_EVENT_TOOL_TYPE_ERASER) { + mPenSeen = true; + } + if (toolType == AMOTION_EVENT_TOOL_TYPE_MOUSE) { + mMouseSeen = true; + } + InputSink& input = this->input(); + + if (toolType == AMOTION_EVENT_TOOL_TYPE_FINGER && handleTouchGesture(event, action)) { + return true; + } + + const auto position = [&](size_t history) -> Vec2F { + if (history == SIZE_MAX) { + return { AMotionEvent_getX(event, 0) / mScale, AMotionEvent_getY(event, 0) / mScale }; + } + return { AMotionEvent_getHistoricalX(event, 0, history) / mScale, + AMotionEvent_getHistoricalY(event, 0, history) / mScale }; + }; + const auto sample = [&](size_t history) { + PointerSample result; + result.device = deviceFor(toolType); + result.tip = tipFor(toolType); + const bool current = history == SIZE_MAX; + result.pressure = + current ? AMotionEvent_getPressure(event, 0) : AMotionEvent_getHistoricalPressure(event, 0, history); + const halnf tilt = current ? AMotionEvent_getAxisValue(event, AMOTION_EVENT_AXIS_TILT, 0) + : AMotionEvent_getHistoricalAxisValue(event, AMOTION_EVENT_AXIS_TILT, 0, history); + const halnf orientation = + current ? AMotionEvent_getOrientation(event, 0) : AMotionEvent_getHistoricalOrientation(event, 0, history); + result.tilt = { std::sin(orientation) * tilt, -std::cos(orientation) * tilt }; + result.rotation = orientation; + result.timeMs = + (uint32_t) ((current ? AMotionEvent_getEventTime(event) : AMotionEvent_getHistoricalEventTime(event, history)) / + 1000000); + if (!result.isStylus()) { + result.pressure = 1; + } else if (result.pressure > 0) { + mLastContactPressure = result.pressure; + } + return result; + }; + + switch (action) { + case AMOTION_EVENT_ACTION_DOWN: + case AMOTION_EVENT_ACTION_BUTTON_PRESS: + { + input.post(PointerSampled{ sample(SIZE_MAX) }); + input.post(PointerMoved{ position(SIZE_MAX) }); + if (action == AMOTION_EVENT_ACTION_BUTTON_PRESS) { + deliverButtonChanges(input, AMotionEvent_getButtonState(event)); + } else if ( + !mPrimaryHeld && (AMotionEvent_getButtonState(event) == 0 || toolType != AMOTION_EVENT_TOOL_TYPE_MOUSE) + ) { + mPrimaryHeld = true; + input.post(PointerButtonChanged{ PointerButton::Left, true }); + } + return true; + } + case AMOTION_EVENT_ACTION_MOVE: + case AMOTION_EVENT_ACTION_HOVER_MOVE: + { + const size_t history = AMotionEvent_getHistorySize(event); + for (size_t index = 0; index < history; index++) { + input.post(PointerSampled{ sample(index) }); + input.post(PointerMoved{ position(index) }); + } + input.post(PointerSampled{ sample(SIZE_MAX) }); + input.post(PointerMoved{ position(SIZE_MAX) }); + return true; + } + case AMOTION_EVENT_ACTION_UP: + case AMOTION_EVENT_ACTION_CANCEL: + case AMOTION_EVENT_ACTION_BUTTON_RELEASE: + { + PointerSample released = sample(SIZE_MAX); + if (released.isStylus()) { + released.pressure = mLastContactPressure; + } + input.post(PointerSampled{ released }); + input.post(PointerMoved{ position(SIZE_MAX) }); + if (action == AMOTION_EVENT_ACTION_BUTTON_RELEASE) { + deliverButtonChanges(input, AMotionEvent_getButtonState(event)); + } else if (mPrimaryHeld) { + mPrimaryHeld = false; + input.post(PointerButtonChanged{ PointerButton::Left, false }); + } + if (toolType == AMOTION_EVENT_TOOL_TYPE_FINGER) { + input.post(PointerLeft{}); + } + return true; + } + case AMOTION_EVENT_ACTION_HOVER_EXIT: input.post(PointerLeft{}); return true; + case AMOTION_EVENT_ACTION_SCROLL: + { + const Vec2F delta = { -AMotionEvent_getAxisValue(event, AMOTION_EVENT_AXIS_HSCROLL, 0) * kWheelStep, + AMotionEvent_getAxisValue(event, AMOTION_EVENT_AXIS_VSCROLL, 0) * kWheelStep }; + input.post(PointerMoved{ position(SIZE_MAX) }); + input.post(Scrolled{ delta, ScrollSource::Wheel }); + return true; + } + default: return false; + } + } + + bool AndroidWindow::handleTouchGesture(AInputEvent* event, int32_t action) { + const size_t count = AMotionEvent_getPointerCount(event); + const bool lifting = action == AMOTION_EVENT_ACTION_POINTER_UP; + const size_t liftedIndex = + lifting ? (size_t) ((AMotionEvent_getAction(event) & AMOTION_EVENT_ACTION_POINTER_INDEX_MASK) >> + AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT) + : SIZE_MAX; + const bool ending = action == AMOTION_EVENT_ACTION_UP || action == AMOTION_EVENT_ACTION_CANCEL; + + std::vector touches; + if (!ending) { + for (size_t index = 0; index < count; index++) { + if (index == liftedIndex) { + continue; + } + touches.push_back( + { AMotionEvent_getPointerId(event, index), + { AMotionEvent_getX(event, index) / mScale, AMotionEvent_getY(event, index) / mScale } } + ); + } + } + + if (touches.size() < 2 && !mGestures.isActive() && !mGestureHoldsPointer) { + return false; + } + + InputSink& input = this->input(); + std::vector gestures; + mGestures.update(touches, gestures, (uint32_t) (AMotionEvent_getEventTime(event) / 1000000)); + for (const TouchGesture& gesture : gestures) { + input.post(GestureChanged{ gesture }); + } + + if (touches.size() >= 2 && !mGestureHoldsPointer) { + mGestureHoldsPointer = true; + if (mPrimaryHeld) { + mPrimaryHeld = false; + input.post(PointerButtonChanged{ PointerButton::Left, false }); + } + input.post(PointerLeft{}); + } + + if (ending) { + mGestureHoldsPointer = false; + } + return true; + } + + void AndroidWindow::deliverButtonChanges(InputSink& input, int32_t state) { + const int32_t changed = state ^ mButtonState; + for (int32_t bit = 1; bit <= AMOTION_EVENT_BUTTON_FORWARD; bit <<= 1) { + if (!(changed & bit)) { + continue; + } + if (auto button = buttonFor(bit)) { + input.post(PointerButtonChanged{ *button, (state & bit) != 0 }); + } + } + mButtonState = state; + } + + bool AndroidWindow::handleKey(AInputEvent* event) { + const int32_t keyCode = AKeyEvent_getKeyCode(event); + if (keyCode == AKEYCODE_VOLUME_UP || keyCode == AKEYCODE_VOLUME_DOWN) { + return false; + } + + const int32_t action = AKeyEvent_getAction(event); + InputSink& input = this->input(); + const std::optional key = + keyCode == AKEYCODE_BACK ? std::optional(Key::Escape) : android::keyFromAndroid(keyCode); + if (key && action == AKEY_EVENT_ACTION_DOWN) { + const bool repeats = AKeyEvent_getRepeatCount(event) > 0; + input.post(KeyChanged{ *key, repeats ? KeyAction::Repeat : KeyAction::Press }); + } else if (key && action == AKEY_EVENT_ACTION_UP) { + input.post(KeyChanged{ *key, KeyAction::Release }); + } + + if (action == AKEY_EVENT_ACTION_DOWN) { + const std::string text = android::unicodeText(mApp, event); + if (!text.empty()) { + input.post(TextEntered{ text }); + } + } + return true; + } + + void AndroidWindow::pollOnce(int timeoutMs) { + int events = 0; + android_poll_source* source = nullptr; + if (ALooper_pollOnce(timeoutMs, nullptr, &events, (void**) &source) >= 0 && source) { + source->process(&mApp, source); + } + } + + void AndroidWindow::wake() { + if (mApp.looper) { + ALooper_wake(mApp.looper); + } + } + + void AndroidWindow::dispatchEvents(halnf timeoutMs) { + int timeout = timeoutMs < 0 ? -1 : (int) std::ceil(timeoutMs); + if (!mNativeWindow && timeout >= 0) { + timeout = 100; + } + pollOnce(timeout); + while (true) { + int events = 0; + android_poll_source* source = nullptr; + if (ALooper_pollOnce(0, nullptr, &events, (void**) &source) < 0) { + break; + } + if (source) { + source->process(&mApp, source); + } + if (mApp.destroyRequested) { + break; + } + } + } + + void AndroidWindow::waitUntilConfigured() { + while (!mNativeWindow && !mApp.destroyRequested) { + pollOnce(-1); + } + updateGeometry(); + mConfigured = true; + mHost.onContentResized(); + __android_log_print(ANDROID_LOG_INFO, kTag, "platform %s", describe(platformInfo()).c_str()); + } + + bool AndroidWindow::shouldClose() const { return mApp.destroyRequested != 0; } + + void AndroidWindow::requestClose() { ANativeActivity_finish(mApp.activity); } + + void AndroidWindow::minimize() { ANativeActivity_finish(mApp.activity); } + + bool AndroidWindow::openUrl(const std::string& url) { return android::openUrl(mApp, url); } + + void AndroidWindow::setTextInputEnabled(bool enabled) { + if (enabled == mTextInput) { + return; + } + mTextInput = enabled; + android::setSoftKeyboardVisible(mApp, enabled); + } + + namespace { + std::string systemProperty(const char* name) { + char value[PROP_VALUE_MAX] = {}; + __system_property_get(name, value); + return value; + } + + constexpr int32_t kTabletSmallestWidthDp = 600; + } + + PlatformInfo AndroidWindow::platformInfo() const { + PlatformInfo info; + info.system = OperatingSystem::Android; + info.systemVersion = systemProperty("ro.build.version.release"); + info.deviceName = systemProperty("ro.product.manufacturer") + " " + systemProperty("ro.product.model"); + info.contentScale = mScale; + info.screenSize = mFrame.contentSize(); + info.formFactor = FormFactor::Phone; + if (AConfiguration* config = mApp.config) { + const int32_t smallest = AConfiguration_getSmallestScreenWidthDp(config); + if (smallest >= kTabletSmallestWidthDp) { + info.formFactor = FormFactor::Tablet; + } + if (AConfiguration_getUiModeType(config) == ACONFIGURATION_UI_MODE_TYPE_DESK) { + info.formFactor = FormFactor::Desktop; + } + info.hasTouch = AConfiguration_getTouchscreen(config) == ACONFIGURATION_TOUCHSCREEN_FINGER; + info.hasPhysicalKeyboard = AConfiguration_getKeyboard(config) == ACONFIGURATION_KEYBOARD_QWERTY; + } + info.safeInsets = mSafeInsets; + info.hasPen = mPenSeen; + info.hasMouse = mMouseSeen; + info.hasHoverPointer = mMouseSeen || mPenSeen; + return info; + } + + std::vector platformSurfaceExtensions() { + return { VK_KHR_SURFACE_EXTENSION_NAME, VK_KHR_ANDROID_SURFACE_EXTENSION_NAME }; + } + + // runAndroidApplication extracts the packaged assets into the app's internal storage, which it exports as HOME. + std::filesystem::path platformResourceDirectory() { + const char* home = std::getenv("HOME"); + return home ? std::filesystem::path(home) / "rsc" : std::filesystem::path("rsc"); + } + + std::unique_ptr + createPlatformWindow(PlatformWindowHost& host, Vec2F, const WindowIdentity&, const PlatformContext& context) { + if (!context.androidApp) { + throw std::runtime_error("the Android backend needs PlatformContext::androidApp"); + } + return std::make_unique(host, *context.androidApp); + } +} diff --git a/GraphicApplication/src/backends/android/private/AndroidWindow.hpp b/GraphicApplication/src/backends/android/private/AndroidWindow.hpp new file mode 100644 index 00000000..d2c4be38 --- /dev/null +++ b/GraphicApplication/src/backends/android/private/AndroidWindow.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include "PlatformWindow.hpp" +#include "TouchGesture.hpp" + +struct android_app; +struct ANativeWindow; +struct AInputEvent; + +namespace tp { + + class AndroidWindow final : public PlatformWindow { + public: + AndroidWindow(PlatformWindowHost& host, android_app& app); + ~AndroidWindow() override; + + AndroidWindow(const AndroidWindow&) = delete; + AndroidWindow& operator=(const AndroidWindow&) = delete; + + [[nodiscard]] VkSurfaceKHR createSurface(VkInstance instance) const override; + [[nodiscard]] VkExtent2D framebufferExtent() const override; + [[nodiscard]] bool wantsTranslucency() const override { return false; } + void onTranslucencyResolved(bool translucent) override; + + void setAcceptsInput(bool) override {} + + void dispatchEvents(halnf timeoutMs) override; + void wake() override; + void waitUntilConfigured() override; + [[nodiscard]] bool hasPendingConfigure() const override { return false; } + void applyPendingConfigure() override {} + void commitFrameGeometry() override {} + [[nodiscard]] bool isWaitingForFrame() const override { return mNativeWindow == nullptr; } + + [[nodiscard]] bool shouldClose() const override; + void requestClose() override; + + [[nodiscard]] const WindowFrame& frame() const override { return mFrame; } + [[nodiscard]] halnf scale() const override { return mScale; } + [[nodiscard]] bool isMaximized() const override { return true; } + [[nodiscard]] bool isFocused() const override { return mFocused; } + + void minimize() override; + [[nodiscard]] bool isFullScreen() const override { return true; } + + void setCursor(CursorShape) override {} + void setClipboardText(const std::string& text) override { mClipboard = text; } + [[nodiscard]] std::string clipboardText() override { return mClipboard; } + [[nodiscard]] ClipboardImage clipboardImage() override { return {}; } + bool openUrl(const std::string& url) override; + void setTextInputEnabled(bool enabled) override; + void setTextInputRect(const RectF&) override {} + + [[nodiscard]] PlatformInfo platformInfo() const override; + + private: + static void onCommand(android_app* app, int32_t command); + static int32_t onInput(android_app* app, AInputEvent* event); + + void handleCommand(int32_t command); + [[nodiscard]] bool handleInput(AInputEvent* event); + [[nodiscard]] bool handleMotion(AInputEvent* event); + [[nodiscard]] bool handleKey(AInputEvent* event); + void deliverButtonChanges(InputSink& input, int32_t state); + [[nodiscard]] bool handleTouchGesture(AInputEvent* event, int32_t action); + + void attachWindow(ANativeWindow* window); + void detachWindow(); + void updateGeometry(); + void pollOnce(int timeoutMs); + [[nodiscard]] InputSink& input() { return mHost.rawInput(); } + + PlatformWindowHost& mHost; + android_app& mApp; + ANativeWindow* mNativeWindow = nullptr; + WindowFrame mFrame; + std::string mClipboard; + halnf mScale = 1; + bool mFocused = false; + bool mConfigured = false; + bool mPrimaryHeld = false; + bool mTextInput = false; + EdgeInsets mSafeInsets; + bool mPenSeen = false; + bool mMouseSeen = false; + int32_t mButtonState = 0; + halnf mLastContactPressure = 1; + TouchGestureTracker mGestures; + bool mGestureHoldsPointer = false; + }; +} diff --git a/GraphicApplication/src/backends/android/public/AndroidEntry.hpp b/GraphicApplication/src/backends/android/public/AndroidEntry.hpp new file mode 100644 index 00000000..c2745704 --- /dev/null +++ b/GraphicApplication/src/backends/android/public/AndroidEntry.hpp @@ -0,0 +1,13 @@ +#pragma once + +struct android_app; + +namespace tp { + + using AndroidMain = int (*)(android_app*); + + // Extracts assets, runs main and finishes the activity when main returns or fails. + void runAndroidApplication( + android_app* app, AndroidMain main, const char* const* assetDirectories, int assetDirectoryCount + ); +} diff --git a/GraphicApplication/src/backends/contract/private/PlatformInfo.cpp b/GraphicApplication/src/backends/contract/private/PlatformInfo.cpp new file mode 100644 index 00000000..0cb691a2 --- /dev/null +++ b/GraphicApplication/src/backends/contract/private/PlatformInfo.cpp @@ -0,0 +1,50 @@ +#include "PlatformInfo.hpp" + +#include + +namespace tp { + + const char* toString(OperatingSystem system) { + switch (system) { + case OperatingSystem::Linux: return "Linux"; + case OperatingSystem::Android: return "Android"; + case OperatingSystem::Windows: return "Windows"; + case OperatingSystem::MacOS: return "macOS"; + case OperatingSystem::IOS: return "iOS"; + case OperatingSystem::Unknown: break; + } + return "unknown"; + } + + const char* toString(FormFactor formFactor) { + switch (formFactor) { + case FormFactor::Desktop: return "desktop"; + case FormFactor::Tablet: return "tablet"; + case FormFactor::Phone: return "phone"; + case FormFactor::Unknown: break; + } + return "unknown"; + } + + std::string describe(const PlatformInfo& info) { + char text[512]; + std::snprintf( + text, + sizeof(text), + "%s %s, %s%s%s, scale %.2f, screen %.0fx%.0f, input:%s%s%s%s", + toString(info.system), + info.systemVersion.c_str(), + toString(info.formFactor), + info.deviceName.empty() ? "" : " ", + info.deviceName.c_str(), + info.contentScale, + info.screenSize.x, + info.screenSize.y, + info.hasMouse ? " mouse" : "", + info.hasTouch ? " touch" : "", + info.hasPen ? " pen" : "", + info.hasPhysicalKeyboard ? " keyboard" : "" + ); + return text; + } +} diff --git a/GraphicApplication/src/backends/contract/private/WindowFrame.cpp b/GraphicApplication/src/backends/contract/private/WindowFrame.cpp new file mode 100644 index 00000000..584649ad --- /dev/null +++ b/GraphicApplication/src/backends/contract/private/WindowFrame.cpp @@ -0,0 +1,94 @@ +#include "WindowFrame.hpp" + +#include + +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 }; +} + +RectF WindowFrame::contentRect() const { + const halnf inset = margin(); + return { inset, inset, mContentSize.x, mContentSize.y }; +} + +RectF WindowFrame::inputRect() const { + const RectF content = contentRect(); + const halnf outer = outerResizeBorder(); + return { content.x - outer, content.y - outer, content.z + outer * 2, content.w + outer * 2 }; +} + +halnf WindowFrame::outerResizeBorder() const { return std::min(kResizeBorder, margin()); } + +halnf WindowFrame::innerResizeBorder() const { + return std::max(kMinimumInnerResizeBorder, kResizeBorder - outerResizeBorder()); +} + +WindowEdge WindowFrame::edgeAt(Vec2F point) const { + if (mTiled) { + return WindowEdge::None; + } + + const RectF zone = inputRect(); + const bool insideZone = + point.x >= zone.x && point.y >= zone.y && point.x < zone.x + zone.z && point.y < zone.y + zone.w; + if (!insideZone) { + return WindowEdge::None; + } + + const RectF content = contentRect(); + const halnf inner = innerResizeBorder(); + + const bool onLeft = point.x < content.x + inner; + const bool onRight = point.x >= content.x + content.z - inner; + const bool onTop = point.y < content.y + inner; + const bool onBottom = point.y >= content.y + content.w - inner; + + const bool nearLeft = point.x < content.x + kCornerResizeLength; + const bool nearRight = point.x >= content.x + content.z - kCornerResizeLength; + const bool nearTop = point.y < content.y + kCornerResizeLength; + const bool nearBottom = point.y >= content.y + content.w - kCornerResizeLength; + + if ((onTop && nearLeft) || (onLeft && nearTop)) { + return WindowEdge::TopLeft; + } + if ((onTop && nearRight) || (onRight && nearTop)) { + return WindowEdge::TopRight; + } + if ((onBottom && nearLeft) || (onLeft && nearBottom)) { + return WindowEdge::BottomLeft; + } + if ((onBottom && nearRight) || (onRight && nearBottom)) { + return WindowEdge::BottomRight; + } + if (onTop) { + return WindowEdge::Top; + } + if (onBottom) { + return WindowEdge::Bottom; + } + if (onLeft) { + return WindowEdge::Left; + } + if (onRight) { + return WindowEdge::Right; + } + return WindowEdge::None; +} diff --git a/GraphicApplication/src/backends/contract/public/DesktopWindowControls.hpp b/GraphicApplication/src/backends/contract/public/DesktopWindowControls.hpp new file mode 100644 index 00000000..b9eee666 --- /dev/null +++ b/GraphicApplication/src/backends/contract/public/DesktopWindowControls.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include "Vec.hpp" +#include "WindowTypes.hpp" + +namespace tp { + + // Window manipulation that only desktop window systems offer; absent on other platforms. + class DesktopWindowControls { + public: + virtual ~DesktopWindowControls() = default; + + virtual void toggleMaximize() = 0; + virtual void setFullScreen(bool fullScreen) = 0; + virtual void beginMove() = 0; + virtual void beginResize(WindowEdge edge) = 0; + virtual void showWindowMenu(Vec2F contentPosition) = 0; + }; +} diff --git a/GraphicApplication/src/backends/contract/public/PlatformContext.hpp b/GraphicApplication/src/backends/contract/public/PlatformContext.hpp new file mode 100644 index 00000000..676e9560 --- /dev/null +++ b/GraphicApplication/src/backends/contract/public/PlatformContext.hpp @@ -0,0 +1,11 @@ +#pragma once + +struct android_app; + +namespace tp { + + // Process objects a backend needs to open a window; desktop backends need none. + struct PlatformContext { + android_app* androidApp = nullptr; + }; +} diff --git a/GraphicApplication/src/backends/contract/public/PlatformInfo.hpp b/GraphicApplication/src/backends/contract/public/PlatformInfo.hpp new file mode 100644 index 00000000..fb481118 --- /dev/null +++ b/GraphicApplication/src/backends/contract/public/PlatformInfo.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "Vec.hpp" + +#include + +namespace tp { + + enum class OperatingSystem { Linux, Android, Windows, MacOS, IOS, Unknown }; + + enum class FormFactor { Desktop, Tablet, Phone, Unknown }; + + struct EdgeInsets { + halnf left = 0; + halnf top = 0; + halnf right = 0; + halnf bottom = 0; + + [[nodiscard]] bool operator==(const EdgeInsets& other) const = default; + }; + + struct PlatformInfo { + OperatingSystem system = OperatingSystem::Unknown; + FormFactor formFactor = FormFactor::Unknown; + std::string systemVersion; + std::string deviceName; + + halnf contentScale = 1; + Vec2F screenSize = { 0, 0 }; + EdgeInsets safeInsets; + + bool hasMouse = false; + bool hasTouch = false; + bool hasPen = false; + bool hasPhysicalKeyboard = false; + + bool hasHoverPointer = false; + + [[nodiscard]] bool isTouchFirst() const { + return formFactor == FormFactor::Tablet || formFactor == FormFactor::Phone; + } + }; + + [[nodiscard]] const char* toString(OperatingSystem system); + [[nodiscard]] const char* toString(FormFactor formFactor); + [[nodiscard]] std::string describe(const PlatformInfo& info); +} diff --git a/GraphicApplication/src/backends/contract/public/PlatformWindow.hpp b/GraphicApplication/src/backends/contract/public/PlatformWindow.hpp new file mode 100644 index 00000000..c8580ad3 --- /dev/null +++ b/GraphicApplication/src/backends/contract/public/PlatformWindow.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "InputSink.hpp" +#include "PlatformContext.hpp" +#include "PlatformInfo.hpp" +#include "Rect.hpp" +#include "WindowFrame.hpp" +#include "WindowSurface.hpp" +#include "WindowTypes.hpp" + +#include +#include +#include +#include + +namespace tp { + + class DesktopWindowControls; + + // What a backend reports to the window that owns it. + class PlatformWindowHost { + public: + virtual ~PlatformWindowHost() = default; + + [[nodiscard]] virtual InputSink& rawInput() = 0; + virtual void onContentResized() = 0; + virtual void onSurfaceAvailable() = 0; + virtual void onSurfaceLost() = 0; + }; + + class PlatformWindow : public VulkanSurfaceSource { + public: + ~PlatformWindow() override = default; + + virtual void dispatchEvents(halnf timeoutMs) = 0; + virtual void wake() = 0; + virtual void waitUntilConfigured() = 0; + [[nodiscard]] virtual bool hasPendingConfigure() const = 0; + virtual void applyPendingConfigure() = 0; + virtual void commitFrameGeometry() = 0; + [[nodiscard]] virtual bool isWaitingForFrame() const = 0; + virtual void setAcceptsInput(bool accepts) = 0; + + [[nodiscard]] virtual bool shouldClose() const = 0; + virtual void requestClose() = 0; + + [[nodiscard]] virtual const WindowFrame& frame() const = 0; + [[nodiscard]] virtual halnf scale() const = 0; + [[nodiscard]] virtual bool isMaximized() const = 0; + [[nodiscard]] virtual bool isFullScreen() const = 0; + [[nodiscard]] virtual bool isFocused() const = 0; + virtual void minimize() = 0; + [[nodiscard]] virtual DesktopWindowControls* desktopControls() { return nullptr; } + + virtual void setCursor(CursorShape shape) = 0; + virtual void setClipboardText(const std::string& text) = 0; + [[nodiscard]] virtual std::string clipboardText() = 0; + [[nodiscard]] virtual ClipboardImage clipboardImage() = 0; + virtual bool openUrl(const std::string& url) = 0; + virtual void setTextInputEnabled(bool enabled) = 0; + virtual void setTextInputRect(const RectF& contentRect) = 0; + + [[nodiscard]] virtual PlatformInfo platformInfo() const = 0; + }; + + [[nodiscard]] std::vector platformSurfaceExtensions(); + + [[nodiscard]] std::filesystem::path platformResourceDirectory(); + + [[nodiscard]] std::unique_ptr createPlatformWindow( + PlatformWindowHost& host, Vec2F contentSize, const WindowIdentity& identity, const PlatformContext& context + ); +} diff --git a/GraphicApplication/src/backends/contract/public/WindowFrame.hpp b/GraphicApplication/src/backends/contract/public/WindowFrame.hpp new file mode 100644 index 00000000..ac8db063 --- /dev/null +++ b/GraphicApplication/src/backends/contract/public/WindowFrame.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "Rect.hpp" +#include "WindowTypes.hpp" + +namespace tp { + + class WindowFrame { + public: + static constexpr halnf kShadowMargin = 18; + static constexpr halnf kCornerRadius = 12; + static constexpr halnf kResizeBorder = 10; + static constexpr halnf kMinimumInnerResizeBorder = 3; + static constexpr halnf kCornerResizeLength = 18; + + void setContentSize(Vec2F size) { mContentSize = size; } + void setTiled(bool tiled) { mTiled = tiled; } + void setTranslucent(bool translucent) { mTranslucent = translucent; } + + [[nodiscard]] Vec2F contentSize() const { return mContentSize; } + [[nodiscard]] bool isTiled() const { return mTiled; } + [[nodiscard]] bool isTranslucent() const { return mTranslucent; } + + [[nodiscard]] bool hasShadow() const { return mTranslucent && !mTiled; } + [[nodiscard]] halnf margin() const { return hasShadow() ? kShadowMargin : 0; } + [[nodiscard]] halnf cornerRadius() const { return hasShadow() ? kCornerRadius : 0; } + + [[nodiscard]] Vec2F surfaceSize() const; + [[nodiscard]] RectF contentRect() const; + [[nodiscard]] RectF inputRect() const; + + [[nodiscard]] WindowEdge edgeAt(Vec2F surfacePoint) const; + + private: + [[nodiscard]] halnf outerResizeBorder() const; + [[nodiscard]] halnf innerResizeBorder() const; + + Vec2F mContentSize = { 0, 0 }; + bool mTiled = false; + bool mTranslucent = true; + }; +} diff --git a/GraphicApplication/src/backends/contract/public/WindowTypes.hpp b/GraphicApplication/src/backends/contract/public/WindowTypes.hpp new file mode 100644 index 00000000..1edee226 --- /dev/null +++ b/GraphicApplication/src/backends/contract/public/WindowTypes.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include + +namespace tp { + + struct ClipboardImage { + std::string mimeType; + std::string bytes; + + [[nodiscard]] bool isEmpty() const { return bytes.empty(); } + }; + + enum class CursorShape { + Default, + Pointer, + Text, + Move, + Grab, + Grabbing, + ResizeHorizontal, + ResizeVertical, + ResizeNorthWestSouthEast, + ResizeNorthEastSouthWest, + NotAllowed, + Crosshair, + Hidden + }; + + struct WindowIdentity { + std::string appId; + std::string title; + + [[nodiscard]] static WindowIdentity generic(); + }; + + enum class WindowEdge { None, Top, Bottom, Left, Right, TopLeft, TopRight, BottomLeft, BottomRight }; + + CursorShape cursorForEdge(WindowEdge edge); +} diff --git a/GraphicApplication/src/backends/wayland/private/LinuxInputCodes.cpp b/GraphicApplication/src/backends/wayland/private/LinuxInputCodes.cpp new file mode 100644 index 00000000..a44bc867 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/LinuxInputCodes.cpp @@ -0,0 +1,153 @@ +#include "LinuxInputCodes.hpp" + +#include + +#include + +using namespace tp; + +namespace { + const std::unordered_map& evdevKeys() { + static const std::unordered_map keys = { + { KEY_SPACE, Key::Space }, + { KEY_APOSTROPHE, Key::Apostrophe }, + { KEY_COMMA, Key::Comma }, + { KEY_MINUS, Key::Minus }, + { KEY_DOT, Key::Period }, + { KEY_SLASH, Key::Slash }, + { KEY_0, Key::Digit0 }, + { KEY_1, Key::Digit1 }, + { KEY_2, Key::Digit2 }, + { KEY_3, Key::Digit3 }, + { KEY_4, Key::Digit4 }, + { KEY_5, Key::Digit5 }, + { KEY_6, Key::Digit6 }, + { KEY_7, Key::Digit7 }, + { KEY_8, Key::Digit8 }, + { KEY_9, Key::Digit9 }, + { KEY_SEMICOLON, Key::Semicolon }, + { KEY_EQUAL, Key::Equal }, + { KEY_A, Key::A }, + { KEY_B, Key::B }, + { KEY_C, Key::C }, + { KEY_D, Key::D }, + { KEY_E, Key::E }, + { KEY_F, Key::F }, + { KEY_G, Key::G }, + { KEY_H, Key::H }, + { KEY_I, Key::I }, + { KEY_J, Key::J }, + { KEY_K, Key::K }, + { KEY_L, Key::L }, + { KEY_M, Key::M }, + { KEY_N, Key::N }, + { KEY_O, Key::O }, + { KEY_P, Key::P }, + { KEY_Q, Key::Q }, + { KEY_R, Key::R }, + { KEY_S, Key::S }, + { KEY_T, Key::T }, + { KEY_U, Key::U }, + { KEY_V, Key::V }, + { KEY_W, Key::W }, + { KEY_X, Key::X }, + { KEY_Y, Key::Y }, + { KEY_Z, Key::Z }, + { KEY_LEFTBRACE, Key::LeftBracket }, + { KEY_BACKSLASH, Key::Backslash }, + { KEY_RIGHTBRACE, Key::RightBracket }, + { KEY_GRAVE, Key::GraveAccent }, + { KEY_102ND, Key::World2 }, + { KEY_ESC, Key::Escape }, + { KEY_ENTER, Key::Enter }, + { KEY_TAB, Key::Tab }, + { KEY_BACKSPACE, Key::Backspace }, + { KEY_INSERT, Key::Insert }, + { KEY_DELETE, Key::Delete }, + { KEY_RIGHT, Key::Right }, + { KEY_LEFT, Key::Left }, + { KEY_DOWN, Key::Down }, + { KEY_UP, Key::Up }, + { KEY_PAGEUP, Key::PageUp }, + { KEY_PAGEDOWN, Key::PageDown }, + { KEY_HOME, Key::Home }, + { KEY_END, Key::End }, + { KEY_CAPSLOCK, Key::CapsLock }, + { KEY_SCROLLLOCK, Key::ScrollLock }, + { KEY_NUMLOCK, Key::NumLock }, + { KEY_SYSRQ, Key::PrintScreen }, + { KEY_PAUSE, Key::Pause }, + { KEY_F1, Key::F1 }, + { KEY_F2, Key::F2 }, + { KEY_F3, Key::F3 }, + { KEY_F4, Key::F4 }, + { KEY_F5, Key::F5 }, + { KEY_F6, Key::F6 }, + { KEY_F7, Key::F7 }, + { KEY_F8, Key::F8 }, + { KEY_F9, Key::F9 }, + { KEY_F10, Key::F10 }, + { KEY_F11, Key::F11 }, + { KEY_F12, Key::F12 }, + { KEY_F13, Key::F13 }, + { KEY_F14, Key::F14 }, + { KEY_F15, Key::F15 }, + { KEY_F16, Key::F16 }, + { KEY_F17, Key::F17 }, + { KEY_F18, Key::F18 }, + { KEY_F19, Key::F19 }, + { KEY_F20, Key::F20 }, + { KEY_F21, Key::F21 }, + { KEY_F22, Key::F22 }, + { KEY_F23, Key::F23 }, + { KEY_F24, Key::F24 }, + { KEY_KP0, Key::Keypad0 }, + { KEY_KP1, Key::Keypad1 }, + { KEY_KP2, Key::Keypad2 }, + { KEY_KP3, Key::Keypad3 }, + { KEY_KP4, Key::Keypad4 }, + { KEY_KP5, Key::Keypad5 }, + { KEY_KP6, Key::Keypad6 }, + { KEY_KP7, Key::Keypad7 }, + { KEY_KP8, Key::Keypad8 }, + { KEY_KP9, Key::Keypad9 }, + { KEY_KPDOT, Key::KeypadDecimal }, + { KEY_KPSLASH, Key::KeypadDivide }, + { KEY_KPASTERISK, Key::KeypadMultiply }, + { KEY_KPMINUS, Key::KeypadSubtract }, + { KEY_KPPLUS, Key::KeypadAdd }, + { KEY_KPENTER, Key::KeypadEnter }, + { KEY_KPEQUAL, Key::KeypadEqual }, + { KEY_LEFTSHIFT, Key::LeftShift }, + { KEY_LEFTCTRL, Key::LeftControl }, + { KEY_LEFTALT, Key::LeftAlt }, + { KEY_LEFTMETA, Key::LeftSuper }, + { KEY_RIGHTSHIFT, Key::RightShift }, + { KEY_RIGHTCTRL, Key::RightControl }, + { KEY_RIGHTALT, Key::RightAlt }, + { KEY_RIGHTMETA, Key::RightSuper }, + { KEY_COMPOSE, Key::Menu }, + }; + return keys; + } +} + +std::optional tp::keyFromEvdev(uint32_t evdevKey) { + const auto& keys = evdevKeys(); + const auto found = keys.find(evdevKey); + if (found == keys.end()) { + return std::nullopt; + } + return found->second; +} + +std::optional tp::pointerButtonFromLinux(uint32_t linuxButton) { + switch (linuxButton) { + case BTN_LEFT: return PointerButton::Left; + case BTN_RIGHT: return PointerButton::Right; + case BTN_MIDDLE: return PointerButton::Middle; + case BTN_SIDE: return PointerButton::Back; + case BTN_EXTRA: return PointerButton::Forward; + default: return std::nullopt; + } +} diff --git a/GraphicApplication/src/backends/wayland/private/LinuxInputCodes.hpp b/GraphicApplication/src/backends/wayland/private/LinuxInputCodes.hpp new file mode 100644 index 00000000..65f25007 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/LinuxInputCodes.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "InputCodes.hpp" + +#include +#include + +namespace tp { + + [[nodiscard]] std::optional keyFromEvdev(uint32_t evdevKey); + + [[nodiscard]] std::optional pointerButtonFromLinux(uint32_t linuxButton); +} diff --git a/GraphicApplication/src/backends/wayland/private/PointerPinchConverter.cpp b/GraphicApplication/src/backends/wayland/private/PointerPinchConverter.cpp new file mode 100644 index 00000000..602dd94d --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/PointerPinchConverter.cpp @@ -0,0 +1,38 @@ +#include "PointerPinchConverter.hpp" + +#include + +namespace tp { + + TouchGesture PointerPinchConverter::begin(Vec2F center) { + mActive = true; + mTotalScale = 1; + return { GesturePhase::Begin, center }; + } + + std::optional + PointerPinchConverter::update(Vec2F center, Vec2F pan, halnf totalScale, halnf rotationDegrees) { + if (!mActive) { + return std::nullopt; + } + + TouchGesture gesture; + gesture.phase = GesturePhase::Update; + gesture.center = center; + gesture.pan = pan; + gesture.scale = mTotalScale > 0 && totalScale > 0 ? totalScale / mTotalScale : 1; + gesture.rotation = wrapAngle(rotationDegrees * std::numbers::pi_v / 180); + if (totalScale > 0) { + mTotalScale = totalScale; + } + return gesture; + } + + std::optional PointerPinchConverter::end(Vec2F center) { + if (!mActive) { + return std::nullopt; + } + mActive = false; + return TouchGesture{ GesturePhase::End, center }; + } +} diff --git a/GraphicApplication/src/backends/wayland/private/PointerPinchConverter.hpp b/GraphicApplication/src/backends/wayland/private/PointerPinchConverter.hpp new file mode 100644 index 00000000..888bdb57 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/PointerPinchConverter.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "TouchGesture.hpp" + +#include + +namespace tp { + + // Converts touchpad pinch updates (scale relative to the gesture start, pan and rotation per event) into + // incremental TouchGesture updates. + class PointerPinchConverter { + public: + [[nodiscard]] TouchGesture begin(Vec2F center); + [[nodiscard]] std::optional update(Vec2F center, Vec2F pan, halnf totalScale, halnf rotationDegrees); + [[nodiscard]] std::optional end(Vec2F center); + + private: + halnf mTotalScale = 1; + bool mActive = false; + }; +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandClipboard.cpp b/GraphicApplication/src/backends/wayland/private/WaylandClipboard.cpp new file mode 100644 index 00000000..aa806433 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandClipboard.cpp @@ -0,0 +1,260 @@ +#include "WaylandClipboard.hpp" + +#include "WaylandConnection.hpp" + +#include + +#include +#include +#include +#include + +namespace tp { + + namespace { + constexpr const char* kTextMimeType = "text/plain;charset=utf-8"; + constexpr const char* kLegacyTextMimeType = "text/plain"; + constexpr const char* kPngMimeType = "image/png"; + constexpr const char* kImageMimePrefix = "image/"; + + WaylandClipboard& clipboard(void* data) { return *static_cast(data); } + + void offerMimeType(void* data, wl_data_offer* offer, const char* mimeType) { + clipboard(data).onOfferMimeType(offer, mimeType); + } + void offerSourceActions(void*, wl_data_offer*, uint32_t) {} + void offerAction(void*, wl_data_offer*, uint32_t) {} + + void dataOffer(void* data, wl_data_device*, wl_data_offer* offer) { clipboard(data).onOffer(offer); } + void dataEnter(void*, wl_data_device*, uint32_t, wl_surface*, wl_fixed_t, wl_fixed_t, wl_data_offer* offer) { + if (offer) { + wl_data_offer_destroy(offer); + } + } + void dataLeave(void*, wl_data_device*) {} + void dataMotion(void*, wl_data_device*, uint32_t, wl_fixed_t, wl_fixed_t) {} + void dataDrop(void*, wl_data_device*) {} + void dataSelection(void* data, wl_data_device*, wl_data_offer* offer) { clipboard(data).onSelection(offer); } + + void sourceTarget(void*, wl_data_source*, const char*) {} + void sourceSend(void* data, wl_data_source*, const char* mimeType, int32_t descriptor) { + clipboard(data).onSourceSend(mimeType, descriptor); + } + void sourceCancelled(void* data, wl_data_source* source) { clipboard(data).onSourceCancelled(source); } + void sourceDropPerformed(void*, wl_data_source*) {} + void sourceDndFinished(void*, wl_data_source*) {} + void sourceAction(void*, wl_data_source*, uint32_t) {} + + const wl_data_offer_listener kOfferListener = { offerMimeType, offerSourceActions, offerAction }; + + const wl_data_device_listener kDataDeviceListener = { + dataOffer, dataEnter, dataLeave, dataMotion, dataDrop, dataSelection, + }; + + const wl_data_source_listener kSourceListener = { + sourceTarget, sourceSend, sourceCancelled, sourceDropPerformed, sourceDndFinished, sourceAction, + }; + + void writeAll(int descriptor, const std::string& text) { + size_t written = 0; + while (written < text.size()) { + const ssize_t result = write(descriptor, text.data() + written, text.size() - written); + if (result < 0 && errno == EINTR) { + continue; + } + if (result <= 0) { + return; + } + written += (size_t) result; + } + } + + const std::string* preferredImageType(const std::vector& types) { + const std::string* chosen = nullptr; + for (const std::string& type : types) { + if (type == kPngMimeType) { + return &type; + } + if (!chosen && type.starts_with(kImageMimePrefix)) { + chosen = &type; + } + } + return chosen; + } + + bool offers(const std::vector& types, const char* mimeType) { + return std::find(types.begin(), types.end(), mimeType) != types.end(); + } + } + + WaylandClipboard::WaylandClipboard(WaylandSeatContext& seat, wl_data_device_manager* manager, wl_seat* waylandSeat) : + mSeat(seat), + mManager(manager) { + mDevice = wl_data_device_manager_get_data_device(manager, waylandSeat); + wl_data_device_add_listener(mDevice, &kDataDeviceListener, this); + } + + WaylandClipboard::~WaylandClipboard() { + closeTransfer(mText); + closeTransfer(mImage); + if (mSource) { + wl_data_source_destroy(mSource); + } + if (mSelection) { + wl_data_offer_destroy(mSelection); + } + if (mPendingOffer && mPendingOffer != mSelection) { + wl_data_offer_destroy(mPendingOffer); + } + if (mDevice) { + wl_data_device_release(mDevice); + } + } + + void WaylandClipboard::setText(const std::string& text) { + mOwnedText = text; + if (mSource) { + wl_data_source_destroy(mSource); + } + + mSource = wl_data_device_manager_create_data_source(mManager); + wl_data_source_add_listener(mSource, &kSourceListener, this); + wl_data_source_offer(mSource, kTextMimeType); + wl_data_source_offer(mSource, kLegacyTextMimeType); + wl_data_device_set_selection(mDevice, mSource, mSeat.lastInputSerial()); + mSeat.connection().flush(); + } + + std::string WaylandClipboard::text() const { + if (mSource) { + return mOwnedText; + } + return mText.complete ? mText.bytes : std::string(); + } + + ClipboardImage WaylandClipboard::image() const { + if (mSource || !mImage.complete) { + return {}; + } + return { mImage.mimeType, mImage.bytes }; + } + + void WaylandClipboard::startTransfer(Transfer& transfer, const std::string& mimeType) { + closeTransfer(transfer); + transfer = Transfer{ mimeType }; + + int pipeDescriptors[2]; + if (pipe2(pipeDescriptors, O_CLOEXEC | O_NONBLOCK) != 0) { + transfer.complete = true; + return; + } + wl_data_offer_receive(mSelection, mimeType.c_str(), pipeDescriptors[1]); + close(pipeDescriptors[1]); + transfer.descriptor = pipeDescriptors[0]; + } + + void WaylandClipboard::readAvailable(Transfer& transfer) { + char buffer[4096]; + while (true) { + const ssize_t result = read(transfer.descriptor, buffer, sizeof(buffer)); + if (result > 0) { + transfer.bytes.append(buffer, (size_t) result); + continue; + } + if (result < 0 && errno == EINTR) { + continue; + } + if (result < 0 && errno == EAGAIN) { + return; + } + closeTransfer(transfer); + transfer.complete = true; + return; + } + } + + void WaylandClipboard::closeTransfer(Transfer& transfer) { + if (transfer.descriptor >= 0) { + close(transfer.descriptor); + } + transfer.descriptor = -1; + } + + void WaylandClipboard::collectPollDescriptors(std::vector& descriptors) const { + for (const Transfer* transfer : { &mText, &mImage }) { + if (transfer->descriptor >= 0) { + descriptors.push_back({ transfer->descriptor, POLLIN, 0 }); + } + } + } + + void WaylandClipboard::onDescriptorsReady(const std::vector& descriptors) { + for (const pollfd& ready : descriptors) { + if (!(ready.revents & (POLLIN | POLLHUP | POLLERR))) { + continue; + } + for (Transfer* transfer : { &mText, &mImage }) { + if (transfer->descriptor == ready.fd) { + readAvailable(*transfer); + } + } + } + } + + void WaylandClipboard::forgetSelection() { + closeTransfer(mText); + closeTransfer(mImage); + mText = {}; + mImage = {}; + if (mSelection) { + wl_data_offer_destroy(mSelection); + } + mSelection = nullptr; + } + + void WaylandClipboard::onOffer(wl_data_offer* offer) { + mPendingOffer = offer; + mPendingOfferTypes.clear(); + wl_data_offer_add_listener(offer, &kOfferListener, this); + } + + void WaylandClipboard::onOfferMimeType(wl_data_offer* offer, const char* mimeType) { + if (offer == mPendingOffer) { + mPendingOfferTypes.emplace_back(mimeType); + } + } + + void WaylandClipboard::onSelection(wl_data_offer* offer) { + const std::vector types = + offer && offer == mPendingOffer ? std::move(mPendingOfferTypes) : std::vector{}; + if (mSelection != offer) { + forgetSelection(); + } + mSelection = offer; + mPendingOfferTypes.clear(); + mPendingOffer = nullptr; + if (!mSelection) { + return; + } + + if (offers(types, kTextMimeType)) { + startTransfer(mText, kTextMimeType); + } + if (const std::string* imageType = preferredImageType(types)) { + startTransfer(mImage, *imageType); + } + mSeat.connection().flush(); + } + + void WaylandClipboard::onSourceSend(const char*, int descriptor) { + writeAll(descriptor, mOwnedText); + close(descriptor); + } + + void WaylandClipboard::onSourceCancelled(wl_data_source* source) { + if (source == mSource) { + mSource = nullptr; + } + wl_data_source_destroy(source); + } +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandClipboard.hpp b/GraphicApplication/src/backends/wayland/private/WaylandClipboard.hpp new file mode 100644 index 00000000..2a41b5f0 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandClipboard.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "WaylandSeatContext.hpp" +#include "WindowTypes.hpp" + +#include + +#include +#include + +struct wl_data_device; +struct wl_data_device_manager; +struct wl_data_offer; +struct wl_data_source; +struct wl_seat; + +namespace tp { + + // The selection clipboard. Offered contents are read asynchronously on the event loop, so reading the clipboard + // never blocks the UI thread; a selection that is still arriving reads as empty. + class WaylandClipboard { + public: + WaylandClipboard(WaylandSeatContext& seat, wl_data_device_manager* manager, wl_seat* waylandSeat); + ~WaylandClipboard(); + + WaylandClipboard(const WaylandClipboard&) = delete; + WaylandClipboard& operator=(const WaylandClipboard&) = delete; + + void setText(const std::string& text); + [[nodiscard]] std::string text() const; + [[nodiscard]] ClipboardImage image() const; + + void collectPollDescriptors(std::vector& descriptors) const; + void onDescriptorsReady(const std::vector& descriptors); + + void onOffer(wl_data_offer* offer); + void onOfferMimeType(wl_data_offer* offer, const char* mimeType); + void onSelection(wl_data_offer* offer); + void onSourceSend(const char* mimeType, int descriptor); + void onSourceCancelled(wl_data_source* source); + + private: + struct Transfer { + std::string mimeType; + int descriptor = -1; + std::string bytes; + bool complete = false; + }; + + void startTransfer(Transfer& transfer, const std::string& mimeType); + static void readAvailable(Transfer& transfer); + static void closeTransfer(Transfer& transfer); + void forgetSelection(); + + WaylandSeatContext& mSeat; + wl_data_device_manager* mManager; + wl_data_device* mDevice = nullptr; + wl_data_offer* mPendingOffer = nullptr; + std::vector mPendingOfferTypes; + wl_data_offer* mSelection = nullptr; + wl_data_source* mSource = nullptr; + std::string mOwnedText; + Transfer mText; + Transfer mImage; + }; +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandConnection.cpp b/GraphicApplication/src/backends/wayland/private/WaylandConnection.cpp new file mode 100644 index 00000000..386e27dd --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandConnection.cpp @@ -0,0 +1,193 @@ +#include "WaylandConnection.hpp" + +#include "cursor-shape-v1-client-protocol.h" +#include "pointer-gestures-unstable-v1-client-protocol.h" +#include "fractional-scale-v1-client-protocol.h" +#include "tablet-v2-client-protocol.h" +#include "text-input-unstable-v3-client-protocol.h" +#include "viewporter-client-protocol.h" +#include "xdg-activation-v1-client-protocol.h" +#include "xdg-decoration-unstable-v1-client-protocol.h" +#include "xdg-shell-client-protocol.h" + +#include + +#include +#include +#include +#include + +namespace tp { + + struct WaylandRegistryListener { + static void global(void* data, wl_registry* registry, uint32_t name, const char* interface, uint32_t version) { + static_cast(data)->bindGlobal(registry, name, interface, version); + } + + static void globalRemove(void*, wl_registry*, uint32_t) {} + + static void ping(void*, xdg_wm_base* windowManager, uint32_t serial) { xdg_wm_base_pong(windowManager, serial); } + }; + + namespace { + const wl_registry_listener kRegistryListener = { + WaylandRegistryListener::global, + WaylandRegistryListener::globalRemove, + }; + + const xdg_wm_base_listener kWindowManagerListener = { WaylandRegistryListener::ping }; + + template + Interface* + bindAs(wl_registry* registry, uint32_t name, const wl_interface* interface, uint32_t offered, uint32_t wanted) { + return static_cast(wl_registry_bind(registry, name, interface, std::min(offered, wanted))); + } + } + + WaylandConnection::WaylandConnection() { + mDisplay = wl_display_connect(nullptr); + if (!mDisplay) { + throw std::runtime_error("cannot connect to the Wayland display; this build supports Wayland only"); + } + + mRegistry = wl_display_get_registry(mDisplay); + wl_registry_add_listener(mRegistry, &kRegistryListener, this); + wl_display_roundtrip(mDisplay); + wl_display_roundtrip(mDisplay); + + if (!mCompositor || !mWindowManager) { + throw std::runtime_error("Wayland compositor lacks wl_compositor or xdg_wm_base"); + } + } + + WaylandConnection::~WaylandConnection() { + if (mDecorationManager) { + zxdg_decoration_manager_v1_destroy(mDecorationManager); + } + if (mTextInputManager) { + zwp_text_input_manager_v3_destroy(mTextInputManager); + } + if (mActivation) { + xdg_activation_v1_destroy(mActivation); + } + if (mTabletManager) { + zwp_tablet_manager_v2_destroy(mTabletManager); + } + if (mCursorShapeManager) { + wp_cursor_shape_manager_v1_destroy(mCursorShapeManager); + } + if (mPointerGestures) { + zwp_pointer_gestures_v1_destroy(mPointerGestures); + } + if (mFractionalScaleManager) { + wp_fractional_scale_manager_v1_destroy(mFractionalScaleManager); + } + if (mViewporter) { + wp_viewporter_destroy(mViewporter); + } + if (mDataDeviceManager) { + wl_data_device_manager_destroy(mDataDeviceManager); + } + if (mSeat) { + wl_seat_release(mSeat); + } + if (mWindowManager) { + xdg_wm_base_destroy(mWindowManager); + } + if (mCompositor) { + wl_compositor_destroy(mCompositor); + } + if (mRegistry) { + wl_registry_destroy(mRegistry); + } + if (mDisplay) { + wl_display_flush(mDisplay); + wl_display_disconnect(mDisplay); + } + } + + void + WaylandConnection::bindGlobal(wl_registry* registry, uint32_t name, const std::string& interface, uint32_t version) { + if (interface == wl_compositor_interface.name) { + mCompositor = bindAs(registry, name, &wl_compositor_interface, version, 4); + } else if (interface == xdg_wm_base_interface.name) { + mWindowManager = bindAs(registry, name, &xdg_wm_base_interface, version, 5); + xdg_wm_base_add_listener(mWindowManager, &kWindowManagerListener, this); + } else if (interface == wl_seat_interface.name && mSeatName == 0) { + mSeatName = name; + mSeatVersion = version; + } else if (interface == wl_data_device_manager_interface.name) { + mDataDeviceManager = + bindAs(registry, name, &wl_data_device_manager_interface, version, 3); + } else if (interface == wp_viewporter_interface.name) { + mViewporter = bindAs(registry, name, &wp_viewporter_interface, version, 1); + } else if (interface == wp_fractional_scale_manager_v1_interface.name) { + mFractionalScaleManager = + bindAs(registry, name, &wp_fractional_scale_manager_v1_interface, version, 1); + } else if (interface == zwp_pointer_gestures_v1_interface.name) { + mPointerGestures = + bindAs(registry, name, &zwp_pointer_gestures_v1_interface, version, 1); + } else if (interface == wp_cursor_shape_manager_v1_interface.name) { + mCursorShapeManager = + bindAs(registry, name, &wp_cursor_shape_manager_v1_interface, version, 1); + } else if (interface == zwp_text_input_manager_v3_interface.name) { + mTextInputManager = + bindAs(registry, name, &zwp_text_input_manager_v3_interface, version, 1); + } else if (interface == zxdg_decoration_manager_v1_interface.name) { + mDecorationManager = + bindAs(registry, name, &zxdg_decoration_manager_v1_interface, version, 1); + } else if (interface == zwp_tablet_manager_v2_interface.name) { + mTabletManager = bindAs(registry, name, &zwp_tablet_manager_v2_interface, version, 1); + } else if (interface == xdg_activation_v1_interface.name) { + mActivation = bindAs(registry, name, &xdg_activation_v1_interface, version, 1); + } + } + + wl_seat* WaylandConnection::bindSeat(const void* listener, void* data) { + if (mSeat || mSeatName == 0) { + return mSeat; + } + + mSeat = bindAs(mRegistry, mSeatName, &wl_seat_interface, mSeatVersion, 8); + wl_seat_add_listener(mSeat, static_cast(listener), data); + return mSeat; + } + + bool WaylandConnection::dispatch(int timeoutMs) { + if (wl_display_dispatch_pending(mDisplay) > 0) { + flush(); + return true; + } + + while (wl_display_prepare_read(mDisplay) != 0) { + if (wl_display_dispatch_pending(mDisplay) > 0) { + return true; + } + } + + flush(); + + pollfd descriptor = { wl_display_get_fd(mDisplay), POLLIN, 0 }; + const int ready = poll(&descriptor, 1, timeoutMs); + if (ready <= 0) { + wl_display_cancel_read(mDisplay); + return false; + } + + if (wl_display_read_events(mDisplay) < 0) { + throw std::runtime_error("lost the Wayland connection"); + } + wl_display_dispatch_pending(mDisplay); + flush(); + return true; + } + + void WaylandConnection::roundtrip() { wl_display_roundtrip(mDisplay); } + + void WaylandConnection::flush() { + while (wl_display_flush(mDisplay) < 0 && errno == EAGAIN) { + pollfd descriptor = { wl_display_get_fd(mDisplay), POLLOUT, 0 }; + poll(&descriptor, 1, -1); + } + } +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandConnection.hpp b/GraphicApplication/src/backends/wayland/private/WaylandConnection.hpp new file mode 100644 index 00000000..3d4d152a --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandConnection.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +struct wl_display; +struct wl_registry; +struct wl_compositor; +struct wl_seat; +struct wl_data_device_manager; +struct xdg_wm_base; +struct wp_viewporter; +struct wp_fractional_scale_manager_v1; +struct wp_cursor_shape_manager_v1; +struct zwp_pointer_gestures_v1; +struct zwp_text_input_manager_v3; +struct zxdg_decoration_manager_v1; +struct zwp_tablet_manager_v2; +struct xdg_activation_v1; + +namespace tp { + + class WaylandConnection { + public: + WaylandConnection(); + ~WaylandConnection(); + + WaylandConnection(const WaylandConnection&) = delete; + WaylandConnection& operator=(const WaylandConnection&) = delete; + + [[nodiscard]] wl_display* display() const { return mDisplay; } + [[nodiscard]] wl_compositor* compositor() const { return mCompositor; } + [[nodiscard]] xdg_wm_base* windowManager() const { return mWindowManager; } + [[nodiscard]] wl_seat* seat() const { return mSeat; } + [[nodiscard]] wl_seat* bindSeat(const void* listener, void* data); + [[nodiscard]] wl_data_device_manager* dataDeviceManager() const { return mDataDeviceManager; } + [[nodiscard]] wp_viewporter* viewporter() const { return mViewporter; } + [[nodiscard]] wp_fractional_scale_manager_v1* fractionalScaleManager() const { return mFractionalScaleManager; } + [[nodiscard]] wp_cursor_shape_manager_v1* cursorShapeManager() const { return mCursorShapeManager; } + [[nodiscard]] zwp_pointer_gestures_v1* pointerGestures() const { return mPointerGestures; } + [[nodiscard]] zwp_text_input_manager_v3* textInputManager() const { return mTextInputManager; } + [[nodiscard]] zxdg_decoration_manager_v1* decorationManager() const { return mDecorationManager; } + [[nodiscard]] zwp_tablet_manager_v2* tabletManager() const { return mTabletManager; } + [[nodiscard]] xdg_activation_v1* activation() const { return mActivation; } + + bool dispatch(int timeoutMs); + void roundtrip(); + void flush(); + + private: + friend struct WaylandRegistryListener; + + void bindGlobal(wl_registry* registry, uint32_t name, const std::string& interface, uint32_t version); + + wl_display* mDisplay = nullptr; + wl_registry* mRegistry = nullptr; + wl_compositor* mCompositor = nullptr; + xdg_wm_base* mWindowManager = nullptr; + wl_seat* mSeat = nullptr; + uint32_t mSeatName = 0; + uint32_t mSeatVersion = 0; + wl_data_device_manager* mDataDeviceManager = nullptr; + wp_viewporter* mViewporter = nullptr; + wp_fractional_scale_manager_v1* mFractionalScaleManager = nullptr; + wp_cursor_shape_manager_v1* mCursorShapeManager = nullptr; + zwp_pointer_gestures_v1* mPointerGestures = nullptr; + zwp_text_input_manager_v3* mTextInputManager = nullptr; + zxdg_decoration_manager_v1* mDecorationManager = nullptr; + zwp_tablet_manager_v2* mTabletManager = nullptr; + xdg_activation_v1* mActivation = nullptr; + }; +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandKeyboard.cpp b/GraphicApplication/src/backends/wayland/private/WaylandKeyboard.cpp new file mode 100644 index 00000000..eadc7d30 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandKeyboard.cpp @@ -0,0 +1,262 @@ +#include "WaylandKeyboard.hpp" + +#include "LinuxInputCodes.hpp" + +#include + +#include +#include +#include +#include + +namespace tp { + + namespace { + WaylandKeyboard& keyboard(void* data) { return *static_cast(data); } + + void keymap(void* data, wl_keyboard*, uint32_t format, int32_t descriptor, uint32_t size) { + keyboard(data).onKeymap(format, descriptor, size); + } + void keyEnter(void* data, wl_keyboard*, uint32_t serial, wl_surface*, wl_array* keys) { + keyboard(data).onEnter(serial, keys); + } + void keyLeave(void* data, wl_keyboard*, uint32_t, wl_surface*) { keyboard(data).onLeave(); } + void key(void* data, wl_keyboard*, uint32_t serial, uint32_t, uint32_t key, uint32_t state) { + keyboard(data).onKey(serial, key, state); + } + void modifiers( + void* data, wl_keyboard*, uint32_t, uint32_t depressed, uint32_t latched, uint32_t locked, uint32_t group + ) { + keyboard(data).onModifiers(depressed, latched, locked, group); + } + void repeatInfo(void* data, wl_keyboard*, int32_t rate, int32_t delay) { keyboard(data).onRepeatInfo(rate, delay); } + + const wl_keyboard_listener kKeyboardListener = { keymap, keyEnter, keyLeave, key, modifiers, repeatInfo }; + + timespec millisecondsToTimespec(int32_t milliseconds) { + return { milliseconds / 1000, (long) (milliseconds % 1000) * 1000000L }; + } + } + + WaylandKeyboard::WaylandKeyboard(WaylandSeatContext& seat, wl_keyboard* keyboard) : + mSeat(seat), + mKeyboard(keyboard) { + mContext = xkb_context_new(XKB_CONTEXT_NO_FLAGS); + mRepeatTimer = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK); + + const char* locale = std::getenv("LC_ALL"); + if (!locale || !*locale) { + locale = std::getenv("LC_CTYPE"); + } + if (!locale || !*locale) { + locale = std::getenv("LANG"); + } + if (!locale || !*locale) { + locale = "C"; + } + mComposeTable = xkb_compose_table_new_from_locale(mContext, locale, XKB_COMPOSE_COMPILE_NO_FLAGS); + if (mComposeTable) { + mComposeState = xkb_compose_state_new(mComposeTable, XKB_COMPOSE_STATE_NO_FLAGS); + } + + wl_keyboard_add_listener(mKeyboard, &kKeyboardListener, this); + } + + WaylandKeyboard::~WaylandKeyboard() { + if (mRepeatTimer >= 0) { + close(mRepeatTimer); + } + if (mComposeState) { + xkb_compose_state_unref(mComposeState); + } + if (mComposeTable) { + xkb_compose_table_unref(mComposeTable); + } + if (mState) { + xkb_state_unref(mState); + } + if (mKeymap) { + xkb_keymap_unref(mKeymap); + } + if (mContext) { + xkb_context_unref(mContext); + } + wl_keyboard_release(mKeyboard); + } + + void WaylandKeyboard::onKeymap(uint32_t format, int descriptor, uint32_t size) { + if (format != WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1) { + close(descriptor); + return; + } + + void* mapped = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, descriptor, 0); + close(descriptor); + if (mapped == MAP_FAILED) { + return; + } + + xkb_keymap* keymap = xkb_keymap_new_from_string( + mContext, static_cast(mapped), XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS + ); + munmap(mapped, size); + if (!keymap) { + return; + } + + if (mState) { + xkb_state_unref(mState); + } + if (mKeymap) { + xkb_keymap_unref(mKeymap); + } + mKeymap = keymap; + mState = xkb_state_new(mKeymap); + } + + void WaylandKeyboard::onEnter(uint32_t serial, wl_array* keys) { + mSeat.recordSerial(serial); + mSeat.input().post(KeyboardFocusChanged{ true }); + + const auto* pressed = static_cast(keys->data); + const size_t count = keys->size / sizeof(uint32_t); + for (size_t index = 0; index < count; index++) { + if (const std::optional key = keyFromEvdev(pressed[index])) { + mSeat.input().post(KeyChanged{ *key, KeyAction::Press }); + } + } + } + + void WaylandKeyboard::onLeave() { + disarmRepeat(); + if (mComposeState) { + xkb_compose_state_reset(mComposeState); + } + mSeat.input().post(KeyboardFocusChanged{ false }); + } + + void WaylandKeyboard::onKey(uint32_t serial, uint32_t key, uint32_t state) { + mSeat.recordSerial(serial); + + if (state == WL_KEYBOARD_KEY_STATE_PRESSED) { + pressKey(key); + } else { + releaseKey(key); + } + } + + void WaylandKeyboard::pressKey(uint32_t key) { + if (const std::optional id = keyFromEvdev(key)) { + mSeat.input().post(KeyChanged{ *id, KeyAction::Press }); + } + + emitText(key); + + if (mKeymap && xkb_keymap_key_repeats(mKeymap, key + 8)) { + armRepeat(key); + } + } + + void WaylandKeyboard::releaseKey(uint32_t key) { + if (key == mRepeatKey) { + disarmRepeat(); + } + + if (const std::optional id = keyFromEvdev(key)) { + mSeat.input().post(KeyChanged{ *id, KeyAction::Release }); + } + } + + void WaylandKeyboard::emitText(uint32_t key) { + if (!mState) { + return; + } + + const xkb_keysym_t symbol = composed(xkb_state_key_get_one_sym(mState, key + 8)); + if (symbol == XKB_KEY_NoSymbol) { + return; + } + + char utf8[64] = {}; + if (mComposeState && xkb_compose_state_get_status(mComposeState) == XKB_COMPOSE_COMPOSED) { + xkb_compose_state_get_utf8(mComposeState, utf8, sizeof(utf8)); + xkb_compose_state_reset(mComposeState); + } else { + xkb_state_key_get_utf8(mState, key + 8, utf8, sizeof(utf8)); + } + + const unsigned char first = (unsigned char) utf8[0]; + if (first == 0 || first < 0x20 || first == 0x7f) { + return; + } + mSeat.input().post(TextEntered{ utf8 }); + } + + xkb_keysym_t WaylandKeyboard::composed(xkb_keysym_t symbol) { + if (!mComposeState || symbol == XKB_KEY_NoSymbol) { + return symbol; + } + if (xkb_compose_state_feed(mComposeState, symbol) != XKB_COMPOSE_FEED_ACCEPTED) { + return symbol; + } + + switch (xkb_compose_state_get_status(mComposeState)) { + case XKB_COMPOSE_COMPOSING: return XKB_KEY_NoSymbol; + case XKB_COMPOSE_CANCELLED: xkb_compose_state_reset(mComposeState); return XKB_KEY_NoSymbol; + case XKB_COMPOSE_COMPOSED: return xkb_compose_state_get_one_sym(mComposeState); + case XKB_COMPOSE_NOTHING: return symbol; + } + return symbol; + } + + void WaylandKeyboard::onModifiers(uint32_t depressed, uint32_t latched, uint32_t locked, uint32_t group) { + if (mState) { + xkb_state_update_mask(mState, depressed, latched, locked, 0, 0, group); + } + } + + void WaylandKeyboard::onRepeatInfo(int32_t rate, int32_t delay) { + mRepeatRate = rate; + mRepeatDelay = delay; + } + + void WaylandKeyboard::armRepeat(uint32_t key) { + if (mRepeatTimer < 0 || mRepeatRate <= 0) { + return; + } + + mRepeatKey = key; + itimerspec timer = {}; + timer.it_value = millisecondsToTimespec(mRepeatDelay); + timer.it_interval = millisecondsToTimespec(1000 / mRepeatRate); + timerfd_settime(mRepeatTimer, 0, &timer, nullptr); + } + + void WaylandKeyboard::disarmRepeat() { + mRepeatKey = 0; + if (mRepeatTimer < 0) { + return; + } + + itimerspec timer = {}; + timerfd_settime(mRepeatTimer, 0, &timer, nullptr); + } + + void WaylandKeyboard::onRepeatTimer() { + uint64_t expirations = 0; + if (read(mRepeatTimer, &expirations, sizeof(expirations)) != sizeof(expirations)) { + return; + } + if (mRepeatKey == 0) { + return; + } + + const std::optional id = keyFromEvdev(mRepeatKey); + for (uint64_t repeat = 0; repeat < expirations; repeat++) { + if (id) { + mSeat.input().post(KeyChanged{ *id, KeyAction::Repeat }); + } + emitText(mRepeatKey); + } + } +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandKeyboard.hpp b/GraphicApplication/src/backends/wayland/private/WaylandKeyboard.hpp new file mode 100644 index 00000000..24c4e092 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandKeyboard.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "WaylandSeatContext.hpp" + +#include +#include + +#include + +struct wl_keyboard; +struct wl_array; + +namespace tp { + + // wl_keyboard with xkb keymap, compose sequences and client-side key repeat. + class WaylandKeyboard { + public: + WaylandKeyboard(WaylandSeatContext& seat, wl_keyboard* keyboard); + ~WaylandKeyboard(); + + WaylandKeyboard(const WaylandKeyboard&) = delete; + WaylandKeyboard& operator=(const WaylandKeyboard&) = delete; + + [[nodiscard]] int repeatTimerDescriptor() const { return mRepeatTimer; } + void onRepeatTimer(); + + void onKeymap(uint32_t format, int descriptor, uint32_t size); + void onEnter(uint32_t serial, wl_array* keys); + void onLeave(); + void onKey(uint32_t serial, uint32_t key, uint32_t state); + void onModifiers(uint32_t depressed, uint32_t latched, uint32_t locked, uint32_t group); + void onRepeatInfo(int32_t rate, int32_t delay); + + private: + void pressKey(uint32_t key); + void releaseKey(uint32_t key); + void emitText(uint32_t key); + void armRepeat(uint32_t key); + void disarmRepeat(); + [[nodiscard]] xkb_keysym_t composed(xkb_keysym_t symbol); + + WaylandSeatContext& mSeat; + wl_keyboard* mKeyboard; + xkb_context* mContext = nullptr; + xkb_keymap* mKeymap = nullptr; + xkb_state* mState = nullptr; + xkb_compose_table* mComposeTable = nullptr; + xkb_compose_state* mComposeState = nullptr; + int mRepeatTimer = -1; + int32_t mRepeatRate = 25; + int32_t mRepeatDelay = 600; + uint32_t mRepeatKey = 0; + }; +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandPointer.cpp b/GraphicApplication/src/backends/wayland/private/WaylandPointer.cpp new file mode 100644 index 00000000..2298ec15 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandPointer.cpp @@ -0,0 +1,231 @@ +#include "WaylandPointer.hpp" + +#include "WaylandConnection.hpp" + +#include "cursor-shape-v1-client-protocol.h" +#include "pointer-gestures-unstable-v1-client-protocol.h" + +namespace tp { + + namespace { + constexpr halnf kWheelStep = 10; + + WaylandPointer& pointer(void* data) { return *static_cast(data); } + + void pointerEnter(void* data, wl_pointer*, uint32_t serial, wl_surface*, wl_fixed_t x, wl_fixed_t y) { + pointer(data).onEnter(serial, x, y); + } + void pointerLeave(void* data, wl_pointer*, uint32_t, wl_surface*) { pointer(data).onLeave(); } + void pointerMotion(void* data, wl_pointer*, uint32_t, wl_fixed_t x, wl_fixed_t y) { pointer(data).onMotion(x, y); } + void pointerButton(void* data, wl_pointer*, uint32_t serial, uint32_t, uint32_t button, uint32_t state) { + pointer(data).onButton(serial, button, state); + } + void pointerAxis(void* data, wl_pointer*, uint32_t, uint32_t axis, wl_fixed_t value) { + pointer(data).onAxis(axis, value); + } + void pointerFrame(void* data, wl_pointer*) { pointer(data).onFrame(); } + void pointerAxisSource(void* data, wl_pointer*, uint32_t source) { pointer(data).onAxisSource(source); } + void pointerAxisStop(void* data, wl_pointer*, uint32_t, uint32_t) { pointer(data).onAxisStop(); } + void pointerAxisDiscrete(void* data, wl_pointer*, uint32_t axis, int32_t steps) { + pointer(data).onAxisDiscrete(axis, steps); + } + void pointerAxisValue120(void*, wl_pointer*, uint32_t, int32_t) {} + void pointerAxisRelativeDirection(void*, wl_pointer*, uint32_t, uint32_t) {} + + const wl_pointer_listener kPointerListener = { + pointerEnter, + pointerLeave, + pointerMotion, + pointerButton, + pointerAxis, + pointerFrame, + pointerAxisSource, + pointerAxisStop, + pointerAxisDiscrete, + pointerAxisValue120, + pointerAxisRelativeDirection, + }; + + void pinchBegin(void* data, zwp_pointer_gesture_pinch_v1*, uint32_t, uint32_t, wl_surface*, uint32_t) { + pointer(data).onPinchBegin(); + } + void pinchUpdate( + void* data, + zwp_pointer_gesture_pinch_v1*, + uint32_t, + wl_fixed_t dx, + wl_fixed_t dy, + wl_fixed_t scale, + wl_fixed_t rotation + ) { + pointer(data).onPinchUpdate(dx, dy, scale, rotation); + } + void pinchEnd(void* data, zwp_pointer_gesture_pinch_v1*, uint32_t, uint32_t, int32_t) { + pointer(data).onPinchEnd(); + } + + const zwp_pointer_gesture_pinch_v1_listener kPinchListener = { pinchBegin, pinchUpdate, pinchEnd }; + } + + uint32_t cursorShapeCode(CursorShape shape) { + switch (shape) { + case CursorShape::Default: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_DEFAULT; + case CursorShape::Pointer: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_POINTER; + case CursorShape::Text: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_TEXT; + case CursorShape::Move: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_MOVE; + case CursorShape::Grab: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_GRAB; + case CursorShape::Grabbing: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_GRABBING; + case CursorShape::ResizeHorizontal: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_EW_RESIZE; + case CursorShape::ResizeVertical: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_NS_RESIZE; + case CursorShape::ResizeNorthWestSouthEast: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_NWSE_RESIZE; + case CursorShape::ResizeNorthEastSouthWest: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_NESW_RESIZE; + case CursorShape::NotAllowed: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_NOT_ALLOWED; + case CursorShape::Crosshair: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_CROSSHAIR; + case CursorShape::Hidden: return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_DEFAULT; + } + return WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_DEFAULT; + } + + WaylandPointer::WaylandPointer(WaylandSeatContext& seat, wl_pointer* pointer) : + mSeat(seat), + mPointer(pointer) { + wl_pointer_add_listener(mPointer, &kPointerListener, this); + + wp_cursor_shape_manager_v1* manager = seat.connection().cursorShapeManager(); + if (manager) { + mShapeDevice = wp_cursor_shape_manager_v1_get_pointer(manager, mPointer); + } + + zwp_pointer_gestures_v1* gestures = seat.connection().pointerGestures(); + if (gestures) { + mPinch = zwp_pointer_gestures_v1_get_pinch_gesture(gestures, mPointer); + zwp_pointer_gesture_pinch_v1_add_listener(mPinch, &kPinchListener, this); + } + } + + WaylandPointer::~WaylandPointer() { + if (mPinch) { + zwp_pointer_gesture_pinch_v1_destroy(mPinch); + } + if (mShapeDevice) { + wp_cursor_shape_device_v1_destroy(mShapeDevice); + } + wl_pointer_release(mPointer); + } + + void WaylandPointer::onPinchBegin() { + mSeat.surfacePointer().onPointerSurfacePinch(mPinchConverter.begin(mSurfacePosition)); + } + + void WaylandPointer::onPinchUpdate(wl_fixed_t dx, wl_fixed_t dy, wl_fixed_t scale, wl_fixed_t rotation) { + const Vec2F pan = { (halnf) wl_fixed_to_double(dx), (halnf) wl_fixed_to_double(dy) }; + const std::optional gesture = mPinchConverter.update( + mSurfacePosition, pan, (halnf) wl_fixed_to_double(scale), (halnf) wl_fixed_to_double(rotation) + ); + if (gesture) { + mSeat.surfacePointer().onPointerSurfacePinch(*gesture); + } + } + + void WaylandPointer::onPinchEnd() { + if (const std::optional gesture = mPinchConverter.end(mSurfacePosition)) { + mSeat.surfacePointer().onPointerSurfacePinch(*gesture); + } + } + + void WaylandPointer::onEnter(uint32_t serial, wl_fixed_t x, wl_fixed_t y) { + mEnterSerial = serial; + mInside = true; + mSurfacePosition = { (halnf) wl_fixed_to_double(x), (halnf) wl_fixed_to_double(y) }; + mShapeApplied = false; + mSeat.surfacePointer().onPointerSurfaceMove({ (halnf) wl_fixed_to_double(x), (halnf) wl_fixed_to_double(y) }); + applyCursor(); + } + + void WaylandPointer::onLeave() { + mInside = false; + mSeat.surfacePointer().onPointerSurfaceLeft(); + } + + void WaylandPointer::onMotion(wl_fixed_t x, wl_fixed_t y) { + mSurfacePosition = { (halnf) wl_fixed_to_double(x), (halnf) wl_fixed_to_double(y) }; + mSeat.input().post(PointerSampled{}); + mSeat.surfacePointer().onPointerSurfaceMove(mSurfacePosition); + } + + void WaylandPointer::onButton(uint32_t serial, uint32_t button, uint32_t state) { + mSeat.recordSerial(serial); + mSeat.input().post(PointerSampled{}); + mSeat.surfacePointer().onPointerSurfaceButton(button, state == WL_POINTER_BUTTON_STATE_PRESSED, serial); + } + + void WaylandPointer::onAxis(uint32_t axis, wl_fixed_t value) { + if (mDiscreteScroll) { + return; + } + + const halnf amount = (halnf) -wl_fixed_to_double(value) / kWheelStep; + if (axis == WL_POINTER_AXIS_VERTICAL_SCROLL) { + mPendingScroll.y += amount; + } else { + mPendingScroll.x += amount; + } + } + + void WaylandPointer::onAxisDiscrete(uint32_t axis, int32_t steps) { + mDiscreteScroll = true; + if (axis == WL_POINTER_AXIS_VERTICAL_SCROLL) { + mPendingScroll.y -= (halnf) steps; + } else { + mPendingScroll.x -= (halnf) steps; + } + } + + void WaylandPointer::onAxisSource(uint32_t source) { + const bool continuous = source == WL_POINTER_AXIS_SOURCE_FINGER || source == WL_POINTER_AXIS_SOURCE_CONTINUOUS; + mScrollSource = continuous ? ScrollSource::Touchpad : ScrollSource::Wheel; + } + + void WaylandPointer::onAxisStop() { mScrollStopped = true; } + + void WaylandPointer::onFrame() { + InputSink& input = mSeat.input(); + if (mPendingScroll.x != 0 || mPendingScroll.y != 0) { + input.post(Scrolled{ mPendingScroll, mScrollSource }); + } + if (mScrollStopped) { + input.post(ScrollEnded{}); + } + mPendingScroll = { 0, 0 }; + mDiscreteScroll = false; + mScrollStopped = false; + mScrollSource = ScrollSource::Wheel; + } + + void WaylandPointer::setCursor(CursorShape shape) { + if (shape == mShape && mShapeApplied) { + return; + } + + mShape = shape; + mShapeApplied = false; + applyCursor(); + } + + void WaylandPointer::applyCursor() { + if (!mInside || mShapeApplied) { + return; + } + if (mShape == CursorShape::Hidden) { + wl_pointer_set_cursor(mPointer, mEnterSerial, nullptr, 0, 0); + mShapeApplied = true; + return; + } + if (!mShapeDevice) { + return; + } + + wp_cursor_shape_device_v1_set_shape(mShapeDevice, mEnterSerial, cursorShapeCode(mShape)); + mShapeApplied = true; + } +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandPointer.hpp b/GraphicApplication/src/backends/wayland/private/WaylandPointer.hpp new file mode 100644 index 00000000..8c2a20cf --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandPointer.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "PointerPinchConverter.hpp" +#include "WaylandSeatContext.hpp" +#include "WindowTypes.hpp" + +#include + +struct wp_cursor_shape_device_v1; +struct zwp_pointer_gesture_pinch_v1; + +namespace tp { + + // wl_pointer with cursor shapes, frame-grouped scrolling and touchpad pinches. + class WaylandPointer { + public: + WaylandPointer(WaylandSeatContext& seat, wl_pointer* pointer); + ~WaylandPointer(); + + WaylandPointer(const WaylandPointer&) = delete; + WaylandPointer& operator=(const WaylandPointer&) = delete; + + void setCursor(CursorShape shape); + + void onEnter(uint32_t serial, wl_fixed_t x, wl_fixed_t y); + void onLeave(); + void onMotion(wl_fixed_t x, wl_fixed_t y); + void onButton(uint32_t serial, uint32_t button, uint32_t state); + void onAxis(uint32_t axis, wl_fixed_t value); + void onAxisDiscrete(uint32_t axis, int32_t steps); + void onAxisSource(uint32_t source); + void onAxisStop(); + void onFrame(); + + void onPinchBegin(); + void onPinchUpdate(wl_fixed_t dx, wl_fixed_t dy, wl_fixed_t scale, wl_fixed_t rotation); + void onPinchEnd(); + + private: + void applyCursor(); + + WaylandSeatContext& mSeat; + wl_pointer* mPointer; + zwp_pointer_gesture_pinch_v1* mPinch = nullptr; + PointerPinchConverter mPinchConverter; + Vec2F mSurfacePosition = { 0, 0 }; + wp_cursor_shape_device_v1* mShapeDevice = nullptr; + uint32_t mEnterSerial = 0; + bool mInside = false; + CursorShape mShape = CursorShape::Default; + bool mShapeApplied = false; + Vec2F mPendingScroll = { 0, 0 }; + bool mDiscreteScroll = false; + ScrollSource mScrollSource = ScrollSource::Wheel; + bool mScrollStopped = false; + }; + + [[nodiscard]] uint32_t cursorShapeCode(CursorShape shape); +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandSeat.cpp b/GraphicApplication/src/backends/wayland/private/WaylandSeat.cpp new file mode 100644 index 00000000..c699787d --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandSeat.cpp @@ -0,0 +1,113 @@ +#include "WaylandSeat.hpp" + +#include "WaylandClipboard.hpp" +#include "WaylandConnection.hpp" +#include "WaylandKeyboard.hpp" +#include "WaylandPointer.hpp" +#include "WaylandTablet.hpp" +#include "WaylandTextInput.hpp" + +#include + +namespace tp { + + namespace { + void capabilities(void* data, wl_seat*, uint32_t capabilities) { + static_cast(data)->onCapabilities(capabilities); + } + + void name(void*, wl_seat*, const char*) {} + + const wl_seat_listener kSeatListener = { capabilities, name }; + } + + WaylandSeat::WaylandSeat( + WaylandConnection& connection, PlatformWindowHost& host, WaylandSurfacePointer& surfacePointer + ) : + mContext(connection, host, surfacePointer) { + if (!connection.bindSeat(&kSeatListener, this)) { + return; + } + + if (connection.dataDeviceManager()) { + mClipboard = std::make_unique(mContext, connection.dataDeviceManager(), connection.seat()); + } + if (connection.textInputManager()) { + mTextInput = std::make_unique(mContext, connection.textInputManager(), connection.seat()); + } + if (connection.tabletManager()) { + mTablet = std::make_unique(mContext, connection.tabletManager(), connection.seat()); + } + } + + WaylandSeat::~WaylandSeat() = default; + + void WaylandSeat::onCapabilities(uint32_t capabilities) { + const bool hasKeyboard = capabilities & WL_SEAT_CAPABILITY_KEYBOARD; + const bool hasPointer = capabilities & WL_SEAT_CAPABILITY_POINTER; + mHasTouch = capabilities & WL_SEAT_CAPABILITY_TOUCH; + wl_seat* seat = mContext.connection().seat(); + + if (hasKeyboard && !mKeyboard) { + mKeyboard = std::make_unique(mContext, wl_seat_get_keyboard(seat)); + } + if (!hasKeyboard) { + mKeyboard.reset(); + } + + if (hasPointer && !mPointer) { + mPointer = std::make_unique(mContext, wl_seat_get_pointer(seat)); + } + if (!hasPointer) { + mPointer.reset(); + } + } + + void WaylandSeat::collectPollDescriptors(std::vector& descriptors) const { + if (mKeyboard && mKeyboard->repeatTimerDescriptor() >= 0) { + descriptors.push_back({ mKeyboard->repeatTimerDescriptor(), POLLIN, 0 }); + } + if (mClipboard) { + mClipboard->collectPollDescriptors(descriptors); + } + } + + void WaylandSeat::onDescriptorsReady(const std::vector& descriptors) { + for (const pollfd& ready : descriptors) { + if (mKeyboard && ready.fd == mKeyboard->repeatTimerDescriptor() && (ready.revents & POLLIN)) { + mKeyboard->onRepeatTimer(); + } + } + if (mClipboard) { + mClipboard->onDescriptorsReady(descriptors); + } + } + + void WaylandSeat::setCursor(CursorShape shape) { + if (mPointer) { + mPointer->setCursor(shape); + } + } + + void WaylandSeat::setClipboardText(const std::string& text) { + if (mClipboard) { + mClipboard->setText(text); + } + } + + std::string WaylandSeat::clipboardText() const { return mClipboard ? mClipboard->text() : std::string(); } + + ClipboardImage WaylandSeat::clipboardImage() const { return mClipboard ? mClipboard->image() : ClipboardImage{}; } + + void WaylandSeat::setTextInputEnabled(bool enabled) { + if (mTextInput) { + mTextInput->setEnabled(enabled); + } + } + + void WaylandSeat::setTextInputRect(const RectF& surfaceRect) { + if (mTextInput) { + mTextInput->setCursorRect(surfaceRect); + } + } +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandSeat.hpp b/GraphicApplication/src/backends/wayland/private/WaylandSeat.hpp new file mode 100644 index 00000000..df8cd50c --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandSeat.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include "Rect.hpp" +#include "WaylandSeatContext.hpp" +#include "WindowTypes.hpp" + +#include + +#include +#include +#include +#include + +namespace tp { + + class WaylandClipboard; + class WaylandKeyboard; + class WaylandPointer; + class WaylandTablet; + class WaylandTextInput; + + // The wl_seat and the input devices it currently offers. + class WaylandSeat { + public: + WaylandSeat(WaylandConnection& connection, PlatformWindowHost& host, WaylandSurfacePointer& surfacePointer); + ~WaylandSeat(); + + WaylandSeat(const WaylandSeat&) = delete; + WaylandSeat& operator=(const WaylandSeat&) = delete; + + void collectPollDescriptors(std::vector& descriptors) const; + void onDescriptorsReady(const std::vector& descriptors); + + [[nodiscard]] uint32_t lastInputSerial() const { return mContext.lastInputSerial(); } + + void setCursor(CursorShape shape); + + void setClipboardText(const std::string& text); + [[nodiscard]] std::string clipboardText() const; + [[nodiscard]] ClipboardImage clipboardImage() const; + + void setTextInputEnabled(bool enabled); + void setTextInputRect(const RectF& surfaceRect); + + [[nodiscard]] bool hasPointer() const { return mPointer != nullptr; } + [[nodiscard]] bool hasKeyboard() const { return mKeyboard != nullptr; } + [[nodiscard]] bool hasTouch() const { return mHasTouch; } + [[nodiscard]] bool hasTablet() const { return mTablet != nullptr; } + + void onCapabilities(uint32_t capabilities); + + private: + WaylandSeatContext mContext; + std::unique_ptr mKeyboard; + std::unique_ptr mPointer; + std::unique_ptr mClipboard; + std::unique_ptr mTextInput; + std::unique_ptr mTablet; + bool mHasTouch = false; + }; +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandSeatContext.hpp b/GraphicApplication/src/backends/wayland/private/WaylandSeatContext.hpp new file mode 100644 index 00000000..bc7f9c11 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandSeatContext.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "PlatformWindow.hpp" +#include "TouchGesture.hpp" + +#include + +namespace tp { + + class WaylandConnection; + + // Pointer-like devices report in surface coordinates; the window maps them to content or decoration edges. + class WaylandSurfacePointer { + public: + virtual ~WaylandSurfacePointer() = default; + + virtual void onPointerSurfaceMove(Vec2F surfacePoint) = 0; + virtual void onPointerSurfaceButton(uint32_t linuxButton, bool pressed, uint32_t serial) = 0; + virtual void onPointerSurfaceLeft() = 0; + virtual void onPointerSurfacePinch(const TouchGesture& gesture) = 0; + }; + + // What every seat device shares: the connection, the raw input entry, surface pointer routing and the last serial. + class WaylandSeatContext { + public: + WaylandSeatContext(WaylandConnection& connection, PlatformWindowHost& host, WaylandSurfacePointer& surfacePointer) : + mConnection(connection), + mHost(host), + mSurfacePointer(surfacePointer) {} + + [[nodiscard]] WaylandConnection& connection() const { return mConnection; } + [[nodiscard]] InputSink& input() const { return mHost.rawInput(); } + [[nodiscard]] WaylandSurfacePointer& surfacePointer() const { return mSurfacePointer; } + + void recordSerial(uint32_t serial) { mLastInputSerial = serial; } + [[nodiscard]] uint32_t lastInputSerial() const { return mLastInputSerial; } + + private: + WaylandConnection& mConnection; + PlatformWindowHost& mHost; + WaylandSurfacePointer& mSurfacePointer; + uint32_t mLastInputSerial = 0; + }; +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandTablet.cpp b/GraphicApplication/src/backends/wayland/private/WaylandTablet.cpp new file mode 100644 index 00000000..1fd458c4 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandTablet.cpp @@ -0,0 +1,210 @@ +#include "WaylandTablet.hpp" + +#include "WaylandConnection.hpp" + +#include "cursor-shape-v1-client-protocol.h" +#include "tablet-v2-client-protocol.h" + +#include + +#include + +#include + +namespace tp { + + namespace { + WaylandTablet& tablet(void* data) { return *static_cast(data); } + WaylandTablet::Tool& tool(void* data) { return *static_cast(data); } + + void tabletAdded(void* data, zwp_tablet_seat_v2*, zwp_tablet_v2* id) { tablet(data).onTabletAdded(id); } + void toolAdded(void* data, zwp_tablet_seat_v2*, zwp_tablet_tool_v2* id) { tablet(data).onToolAdded(id); } + void padAdded(void* data, zwp_tablet_seat_v2*, zwp_tablet_pad_v2* id) { tablet(data).onPadAdded(id); } + + void tabletName(void*, zwp_tablet_v2*, const char*) {} + void tabletId(void*, zwp_tablet_v2*, uint32_t, uint32_t) {} + void tabletPath(void*, zwp_tablet_v2*, const char*) {} + void tabletDone(void*, zwp_tablet_v2*) {} + void tabletRemoved(void*, zwp_tablet_v2*) {} + void tabletBustype(void*, zwp_tablet_v2*, uint32_t) {} + + void toolType(void* data, zwp_tablet_tool_v2*, uint32_t type) { tool(data).owner.onToolType(tool(data), type); } + void toolSerial(void*, zwp_tablet_tool_v2*, uint32_t, uint32_t) {} + void toolWacomId(void*, zwp_tablet_tool_v2*, uint32_t, uint32_t) {} + void toolCapability(void*, zwp_tablet_tool_v2*, uint32_t) {} + void toolDone(void*, zwp_tablet_tool_v2*) {} + void toolRemoved(void* data, zwp_tablet_tool_v2*) { tool(data).owner.onToolRemoved(tool(data)); } + void toolProximityIn(void* data, zwp_tablet_tool_v2*, uint32_t serial, zwp_tablet_v2*, wl_surface*) { + tool(data).owner.onProximityIn(tool(data), serial); + } + void toolProximityOut(void* data, zwp_tablet_tool_v2*) { tool(data).owner.onProximityOut(tool(data)); } + void toolDown(void* data, zwp_tablet_tool_v2*, uint32_t serial) { + tool(data).owner.recordSerial(serial); + tool(data).contactChange = 1; + } + void toolUp(void* data, zwp_tablet_tool_v2*) { tool(data).contactChange = -1; } + void toolMotion(void* data, zwp_tablet_tool_v2*, wl_fixed_t x, wl_fixed_t y) { + tool(data).position = { (halnf) wl_fixed_to_double(x), (halnf) wl_fixed_to_double(y) }; + tool(data).moved = true; + } + void toolPressure(void* data, zwp_tablet_tool_v2*, uint32_t pressure) { + tool(data).sample.pressure = (halnf) pressure / 65535.0f; + } + void toolDistance(void* data, zwp_tablet_tool_v2*, uint32_t distance) { + tool(data).sample.distance = (halnf) distance / 65535.0f; + } + void toolTilt(void* data, zwp_tablet_tool_v2*, wl_fixed_t x, wl_fixed_t y) { + tool(data).sample.tilt = { (halnf) wl_fixed_to_double(x), (halnf) wl_fixed_to_double(y) }; + } + void toolRotation(void* data, zwp_tablet_tool_v2*, wl_fixed_t degrees) { + tool(data).sample.rotation = (halnf) wl_fixed_to_double(degrees); + } + void toolSlider(void*, zwp_tablet_tool_v2*, int32_t) {} + void toolWheel(void*, zwp_tablet_tool_v2*, wl_fixed_t, int32_t) {} + void toolButton(void* data, zwp_tablet_tool_v2*, uint32_t serial, uint32_t button, uint32_t state) { + tool(data).owner.recordSerial(serial); + const bool pressed = state == ZWP_TABLET_TOOL_V2_BUTTON_STATE_PRESSED; + if (button == BTN_STYLUS) { + tool(data).linuxButtons.push_back({ BTN_RIGHT, pressed }); + } else if (button == BTN_STYLUS2) { + tool(data).linuxButtons.push_back({ BTN_MIDDLE, pressed }); + } + } + void toolFrame(void* data, zwp_tablet_tool_v2*, uint32_t time) { tool(data).owner.onFrame(tool(data), time); } + + void padGroup(void*, zwp_tablet_pad_v2*, zwp_tablet_pad_group_v2*) {} + void padPath(void*, zwp_tablet_pad_v2*, const char*) {} + void padButtons(void*, zwp_tablet_pad_v2*, uint32_t) {} + void padDone(void*, zwp_tablet_pad_v2*) {} + void padButton(void*, zwp_tablet_pad_v2*, uint32_t, uint32_t, uint32_t) {} + void padEnter(void*, zwp_tablet_pad_v2*, uint32_t, zwp_tablet_v2*, wl_surface*) {} + void padLeave(void*, zwp_tablet_pad_v2*, uint32_t, wl_surface*) {} + void padRemoved(void*, zwp_tablet_pad_v2*) {} + + const zwp_tablet_seat_v2_listener kTabletSeatListener = { + tabletAdded, + toolAdded, + padAdded, + }; + + const zwp_tablet_v2_listener kTabletListener = { + tabletName, tabletId, tabletPath, tabletDone, tabletRemoved, tabletBustype, + }; + + const zwp_tablet_tool_v2_listener kToolListener = { + toolType, toolSerial, toolWacomId, toolCapability, toolDone, toolRemoved, toolProximityIn, + toolProximityOut, toolDown, toolUp, toolMotion, toolPressure, toolDistance, toolTilt, + toolRotation, toolSlider, toolWheel, toolButton, toolFrame, + }; + + const zwp_tablet_pad_v2_listener kPadListener = { + padGroup, padPath, padButtons, padDone, padButton, padEnter, padLeave, padRemoved, + }; + } + + WaylandTablet::WaylandTablet(WaylandSeatContext& seat, zwp_tablet_manager_v2* manager, wl_seat* waylandSeat) : + mSeat(seat) { + mTabletSeat = zwp_tablet_manager_v2_get_tablet_seat(manager, waylandSeat); + zwp_tablet_seat_v2_add_listener(mTabletSeat, &kTabletSeatListener, this); + } + + WaylandTablet::~WaylandTablet() { + for (const auto& tool : mTools) { + if (tool->shapeDevice) { + wp_cursor_shape_device_v1_destroy(tool->shapeDevice); + } + zwp_tablet_tool_v2_destroy(tool->handle); + } + for (zwp_tablet_pad_v2* pad : mPads) { + zwp_tablet_pad_v2_destroy(pad); + } + for (zwp_tablet_v2* tablet : mTablets) { + zwp_tablet_v2_destroy(tablet); + } + zwp_tablet_seat_v2_destroy(mTabletSeat); + } + + void WaylandTablet::onTabletAdded(zwp_tablet_v2* tablet) { + mTablets.push_back(tablet); + zwp_tablet_v2_add_listener(tablet, &kTabletListener, this); + } + + void WaylandTablet::onPadAdded(zwp_tablet_pad_v2* pad) { + mPads.push_back(pad); + zwp_tablet_pad_v2_add_listener(pad, &kPadListener, this); + } + + void WaylandTablet::onToolAdded(zwp_tablet_tool_v2* handle) { + mTools.push_back(std::make_unique(Tool{ *this, handle })); + Tool& tool = *mTools.back(); + zwp_tablet_tool_v2_add_listener(handle, &kToolListener, &tool); + + wp_cursor_shape_manager_v1* manager = mSeat.connection().cursorShapeManager(); + if (manager) { + tool.shapeDevice = wp_cursor_shape_manager_v1_get_tablet_tool_v2(manager, handle); + } + } + + void WaylandTablet::onToolRemoved(Tool& tool) { + if (tool.inside) { + onProximityOut(tool); + } + if (tool.shapeDevice) { + wp_cursor_shape_device_v1_destroy(tool.shapeDevice); + } + zwp_tablet_tool_v2_destroy(tool.handle); + std::erase_if(mTools, [&tool](const std::unique_ptr& entry) { return entry.get() == &tool; }); + } + + void WaylandTablet::onToolType(Tool& tool, uint32_t type) { + tool.device = PointerDevice::Stylus; + tool.tip = StylusTip::Pen; + switch (type) { + case ZWP_TABLET_TOOL_V2_TYPE_ERASER: tool.tip = StylusTip::Eraser; break; + case ZWP_TABLET_TOOL_V2_TYPE_BRUSH: tool.tip = StylusTip::Brush; break; + case ZWP_TABLET_TOOL_V2_TYPE_PENCIL: tool.tip = StylusTip::Pencil; break; + case ZWP_TABLET_TOOL_V2_TYPE_AIRBRUSH: tool.tip = StylusTip::Airbrush; break; + case ZWP_TABLET_TOOL_V2_TYPE_FINGER: tool.device = PointerDevice::Finger; break; + case ZWP_TABLET_TOOL_V2_TYPE_MOUSE: + case ZWP_TABLET_TOOL_V2_TYPE_LENS: tool.device = PointerDevice::Mouse; break; + default: break; + } + tool.sample.device = tool.device; + tool.sample.tip = tool.tip; + } + + void WaylandTablet::onProximityIn(Tool& tool, uint32_t serial) { + tool.inside = true; + tool.sample = PointerSample{ tool.device, tool.tip, 0 }; + if (tool.shapeDevice) { + wp_cursor_shape_device_v1_set_shape(tool.shapeDevice, serial, WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_CROSSHAIR); + } + } + + void WaylandTablet::onProximityOut(Tool& tool) { + tool.inside = false; + tool.contactChange = -1; + onFrame(tool, tool.sample.timeMs); + mSeat.surfacePointer().onPointerSurfaceLeft(); + } + + void WaylandTablet::onFrame(Tool& tool, uint32_t time) { + tool.sample.timeMs = time; + mSeat.input().post(PointerSampled{ tool.sample }); + + if (tool.moved && tool.inside) { + mSeat.surfacePointer().onPointerSurfaceMove(tool.position); + } + tool.moved = false; + + if (tool.contactChange != 0) { + mSeat.surfacePointer().onPointerSurfaceButton(BTN_LEFT, tool.contactChange > 0, mSeat.lastInputSerial()); + tool.contactChange = 0; + } + + for (const auto& [linuxButton, pressed] : tool.linuxButtons) { + mSeat.surfacePointer().onPointerSurfaceButton(linuxButton, pressed, mSeat.lastInputSerial()); + } + tool.linuxButtons.clear(); + } +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandTablet.hpp b/GraphicApplication/src/backends/wayland/private/WaylandTablet.hpp new file mode 100644 index 00000000..32c00595 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandTablet.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "WaylandSeatContext.hpp" + +#include +#include +#include + +struct wl_seat; +struct wp_cursor_shape_device_v1; +struct zwp_tablet_manager_v2; +struct zwp_tablet_seat_v2; +struct zwp_tablet_v2; +struct zwp_tablet_tool_v2; +struct zwp_tablet_pad_v2; + +namespace tp { + + // Tablet tools (pens, erasers, ...) reported as pointer samples, motion and buttons. + class WaylandTablet { + public: + WaylandTablet(WaylandSeatContext& seat, zwp_tablet_manager_v2* manager, wl_seat* waylandSeat); + ~WaylandTablet(); + + WaylandTablet(const WaylandTablet&) = delete; + WaylandTablet& operator=(const WaylandTablet&) = delete; + + struct Tool { + WaylandTablet& owner; + zwp_tablet_tool_v2* handle = nullptr; + wp_cursor_shape_device_v1* shapeDevice = nullptr; + PointerSample sample; + PointerDevice device = PointerDevice::Stylus; + StylusTip tip = StylusTip::Pen; + Vec2F position = { 0, 0 }; + bool inside = false; + bool moved = false; + int contactChange = 0; + std::vector> linuxButtons; + }; + + void onTabletAdded(zwp_tablet_v2* tablet); + void onToolAdded(zwp_tablet_tool_v2* tool); + void onPadAdded(zwp_tablet_pad_v2* pad); + + void onToolType(Tool& tool, uint32_t type); + void onProximityIn(Tool& tool, uint32_t serial); + void onProximityOut(Tool& tool); + void onFrame(Tool& tool, uint32_t time); + void onToolRemoved(Tool& tool); + void recordSerial(uint32_t serial) { mSeat.recordSerial(serial); } + + private: + WaylandSeatContext& mSeat; + zwp_tablet_seat_v2* mTabletSeat = nullptr; + std::vector mTablets; + std::vector mPads; + std::vector> mTools; + }; +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandTextInput.cpp b/GraphicApplication/src/backends/wayland/private/WaylandTextInput.cpp new file mode 100644 index 00000000..5b358bc2 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandTextInput.cpp @@ -0,0 +1,95 @@ +#include "WaylandTextInput.hpp" + +#include "text-input-unstable-v3-client-protocol.h" + +namespace tp { + + namespace { + WaylandTextInput& textInput(void* data) { return *static_cast(data); } + + void textEnter(void* data, zwp_text_input_v3*, wl_surface*) { textInput(data).onEnter(); } + void textLeave(void* data, zwp_text_input_v3*, wl_surface*) { textInput(data).onLeave(); } + void textPreedit(void*, zwp_text_input_v3*, const char*, int32_t, int32_t) {} + void textCommit(void* data, zwp_text_input_v3*, const char* text) { textInput(data).onCommitString(text); } + void textDeleteSurrounding(void*, zwp_text_input_v3*, uint32_t, uint32_t) {} + void textDone(void* data, zwp_text_input_v3*, uint32_t) { textInput(data).onDone(); } + + const zwp_text_input_v3_listener kTextInputListener = { + textEnter, textLeave, textPreedit, textCommit, textDeleteSurrounding, textDone, + }; + } + + WaylandTextInput::WaylandTextInput( + WaylandSeatContext& seat, zwp_text_input_manager_v3* manager, wl_seat* waylandSeat + ) : + mSeat(seat) { + mTextInput = zwp_text_input_manager_v3_get_text_input(manager, waylandSeat); + zwp_text_input_v3_add_listener(mTextInput, &kTextInputListener, this); + } + + WaylandTextInput::~WaylandTextInput() { + if (mTextInput) { + zwp_text_input_v3_destroy(mTextInput); + } + } + + void WaylandTextInput::setEnabled(bool enabled) { + if (mEnabled == enabled) { + return; + } + + mEnabled = enabled; + sendState(); + } + + void WaylandTextInput::setCursorRect(const RectF& surfaceRect) { + if (mCursorRect == surfaceRect) { + return; + } + + mCursorRect = surfaceRect; + if (mEnabled && mFocused) { + sendState(); + } + } + + void WaylandTextInput::onEnter() { + mFocused = true; + sendState(); + } + + void WaylandTextInput::onLeave() { + mFocused = false; + zwp_text_input_v3_disable(mTextInput); + zwp_text_input_v3_commit(mTextInput); + } + + void WaylandTextInput::onCommitString(const char* text) { mPendingCommit = text ? text : ""; } + + void WaylandTextInput::onDone() { + if (!mPendingCommit.empty()) { + mSeat.input().post(TextEntered{ mPendingCommit }); + } + mPendingCommit.clear(); + } + + void WaylandTextInput::sendState() { + if (!mFocused) { + return; + } + + if (mEnabled) { + zwp_text_input_v3_enable(mTextInput); + zwp_text_input_v3_set_content_type( + mTextInput, ZWP_TEXT_INPUT_V3_CONTENT_HINT_NONE, ZWP_TEXT_INPUT_V3_CONTENT_PURPOSE_NORMAL + ); + zwp_text_input_v3_set_cursor_rectangle( + mTextInput, (int32_t) mCursorRect.x, (int32_t) mCursorRect.y, (int32_t) mCursorRect.z, (int32_t) mCursorRect.w + ); + } else { + zwp_text_input_v3_disable(mTextInput); + } + + zwp_text_input_v3_commit(mTextInput); + } +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandTextInput.hpp b/GraphicApplication/src/backends/wayland/private/WaylandTextInput.hpp new file mode 100644 index 00000000..b0008f73 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandTextInput.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "Rect.hpp" +#include "WaylandSeatContext.hpp" + +#include + +struct wl_seat; +struct zwp_text_input_manager_v3; +struct zwp_text_input_v3; + +namespace tp { + + // Input-method text through zwp_text_input_v3. + class WaylandTextInput { + public: + WaylandTextInput(WaylandSeatContext& seat, zwp_text_input_manager_v3* manager, wl_seat* waylandSeat); + ~WaylandTextInput(); + + WaylandTextInput(const WaylandTextInput&) = delete; + WaylandTextInput& operator=(const WaylandTextInput&) = delete; + + void setEnabled(bool enabled); + void setCursorRect(const RectF& surfaceRect); + + void onEnter(); + void onLeave(); + void onCommitString(const char* text); + void onDone(); + + private: + void sendState(); + + WaylandSeatContext& mSeat; + zwp_text_input_v3* mTextInput = nullptr; + bool mFocused = false; + bool mEnabled = false; + RectF mCursorRect = { 0, 0, 1, 1 }; + std::string mPendingCommit; + }; +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandWindow.cpp b/GraphicApplication/src/backends/wayland/private/WaylandWindow.cpp new file mode 100644 index 00000000..27795a79 --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandWindow.cpp @@ -0,0 +1,665 @@ +#include "WaylandWindow.hpp" + +#include + +#include "LinuxInputCodes.hpp" +#include "WaylandConnection.hpp" +#include "WaylandSeat.hpp" + +#include "Timing.hpp" + +#include "fractional-scale-v1-client-protocol.h" +#include "viewporter-client-protocol.h" +#include "xdg-activation-v1-client-protocol.h" +#include "xdg-decoration-unstable-v1-client-protocol.h" +#include "xdg-shell-client-protocol.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace tp { + + namespace { + constexpr halnf kFractionalScaleDenominator = 120; + constexpr halnf kMinimumContentWidth = 320; + constexpr halnf kMinimumContentHeight = 200; + constexpr int kConfigureWaitMs = 1000; + } + + struct WaylandWindow::Objects { + wl_surface* surface = nullptr; + xdg_surface* shellSurface = nullptr; + xdg_toplevel* toplevel = nullptr; + wp_viewport* viewport = nullptr; + wp_fractional_scale_v1* fractionalScale = nullptr; + zxdg_toplevel_decoration_v1* decoration = nullptr; + WaylandConfigure pending; + wl_callback* frameCallback = nullptr; + }; + + struct WaylandListeners { + static WaylandWindow& window(void* data) { return *static_cast(data); } + + static void surfaceConfigure(void* data, xdg_surface*, uint32_t serial) { + WaylandWindow& self = window(data); + WaylandConfigure configure = self.mObjects->pending; + configure.serial = serial; + self.mPendingConfigure = configure; + } + + static void toplevelConfigure(void* data, xdg_toplevel*, int32_t width, int32_t height, wl_array* states) { + WaylandConfigure configure; + configure.width = width; + configure.height = height; + + const auto* entries = static_cast(states->data); + const size_t count = states->size / sizeof(uint32_t); + for (size_t index = 0; index < count; index++) { + switch (entries[index]) { + case XDG_TOPLEVEL_STATE_MAXIMIZED: configure.maximized = true; break; + case XDG_TOPLEVEL_STATE_FULLSCREEN: configure.fullscreen = true; break; + case XDG_TOPLEVEL_STATE_ACTIVATED: configure.activated = true; break; + case XDG_TOPLEVEL_STATE_TILED_LEFT: + case XDG_TOPLEVEL_STATE_TILED_RIGHT: + case XDG_TOPLEVEL_STATE_TILED_TOP: + case XDG_TOPLEVEL_STATE_TILED_BOTTOM: configure.tiled = true; break; + default: break; + } + } + + window(data).mObjects->pending = configure; + } + + static void toplevelClose(void* data, xdg_toplevel*) { window(data).mCloseRequested = true; } + static void toplevelBounds(void*, xdg_toplevel*, int32_t, int32_t) {} + static void toplevelCapabilities(void*, xdg_toplevel*, wl_array*) {} + + static void preferredScale(void* data, wp_fractional_scale_v1*, uint32_t scale) { + window(data).applyScale((halnf) scale / kFractionalScaleDenominator); + } + + static void decorationConfigure(void*, zxdg_toplevel_decoration_v1*, uint32_t) {} + + static void frameDone(void* data, wl_callback* callback, uint32_t) { + WaylandWindow& self = window(data); + wl_callback_destroy(callback); + if (self.mObjects->frameCallback == callback) { + self.mObjects->frameCallback = nullptr; + } + } + }; + + namespace { + const xdg_surface_listener kShellSurfaceListener = { WaylandListeners::surfaceConfigure }; + + const xdg_toplevel_listener kToplevelListener = { + WaylandListeners::toplevelConfigure, + WaylandListeners::toplevelClose, + WaylandListeners::toplevelBounds, + WaylandListeners::toplevelCapabilities, + }; + + const wp_fractional_scale_v1_listener kFractionalScaleListener = { WaylandListeners::preferredScale }; + + const zxdg_toplevel_decoration_v1_listener kDecorationListener = { WaylandListeners::decorationConfigure }; + const wl_callback_listener kFrameListener = { WaylandListeners::frameDone }; + + uint32_t resizeEdgeCode(WindowEdge edge) { + switch (edge) { + case WindowEdge::Top: return XDG_TOPLEVEL_RESIZE_EDGE_TOP; + case WindowEdge::Bottom: return XDG_TOPLEVEL_RESIZE_EDGE_BOTTOM; + case WindowEdge::Left: return XDG_TOPLEVEL_RESIZE_EDGE_LEFT; + case WindowEdge::Right: return XDG_TOPLEVEL_RESIZE_EDGE_RIGHT; + case WindowEdge::TopLeft: return XDG_TOPLEVEL_RESIZE_EDGE_TOP_LEFT; + case WindowEdge::TopRight: return XDG_TOPLEVEL_RESIZE_EDGE_TOP_RIGHT; + case WindowEdge::BottomLeft: return XDG_TOPLEVEL_RESIZE_EDGE_BOTTOM_LEFT; + case WindowEdge::BottomRight: return XDG_TOPLEVEL_RESIZE_EDGE_BOTTOM_RIGHT; + case WindowEdge::None: return XDG_TOPLEVEL_RESIZE_EDGE_NONE; + } + return XDG_TOPLEVEL_RESIZE_EDGE_NONE; + } + + uint32_t scaledLength(halnf length, halnf scale) { return (uint32_t) std::lround(length * scale); } + } + + WaylandWindow::WaylandWindow(PlatformWindowHost& host, Vec2F contentSize, const WindowIdentity& identity) : + mHost(host), + mConnection(std::make_unique()), + mObjects(std::make_unique()), + mWakeDescriptor(eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK)) { + mFrame.setContentSize(contentSize); + mRestoredContentSize = contentSize; + + WaylandConnection& connection = *mConnection; + Objects& objects = *mObjects; + + objects.surface = wl_compositor_create_surface(connection.compositor()); + objects.shellSurface = xdg_wm_base_get_xdg_surface(connection.windowManager(), objects.surface); + xdg_surface_add_listener(objects.shellSurface, &kShellSurfaceListener, this); + + objects.toplevel = xdg_surface_get_toplevel(objects.shellSurface); + xdg_toplevel_add_listener(objects.toplevel, &kToplevelListener, this); + xdg_toplevel_set_title(objects.toplevel, identity.title.c_str()); + xdg_toplevel_set_app_id(objects.toplevel, identity.appId.c_str()); + xdg_toplevel_set_min_size(objects.toplevel, (int32_t) kMinimumContentWidth, (int32_t) kMinimumContentHeight); + + if (connection.decorationManager()) { + objects.decoration = + zxdg_decoration_manager_v1_get_toplevel_decoration(connection.decorationManager(), objects.toplevel); + zxdg_toplevel_decoration_v1_add_listener(objects.decoration, &kDecorationListener, this); + zxdg_toplevel_decoration_v1_set_mode(objects.decoration, ZXDG_TOPLEVEL_DECORATION_V1_MODE_CLIENT_SIDE); + } + + if (connection.viewporter()) { + objects.viewport = wp_viewporter_get_viewport(connection.viewporter(), objects.surface); + } + if (connection.fractionalScaleManager() && objects.viewport) { + objects.fractionalScale = + wp_fractional_scale_manager_v1_get_fractional_scale(connection.fractionalScaleManager(), objects.surface); + wp_fractional_scale_v1_add_listener(objects.fractionalScale, &kFractionalScaleListener, this); + } + + mSeat = std::make_unique(connection, mHost, *this); + + updateFrameGeometry(); + wl_surface_commit(objects.surface); + connection.flush(); + } + + WaylandWindow::~WaylandWindow() { + if (mWakeDescriptor >= 0) { + close(mWakeDescriptor); + } + mSeat.reset(); + + Objects& objects = *mObjects; + if (objects.frameCallback) { + wl_callback_destroy(objects.frameCallback); + } + if (objects.decoration) { + zxdg_toplevel_decoration_v1_destroy(objects.decoration); + } + if (objects.fractionalScale) { + wp_fractional_scale_v1_destroy(objects.fractionalScale); + } + if (objects.viewport) { + wp_viewport_destroy(objects.viewport); + } + if (objects.toplevel) { + xdg_toplevel_destroy(objects.toplevel); + } + if (objects.shellSurface) { + xdg_surface_destroy(objects.shellSurface); + } + if (objects.surface) { + wl_surface_destroy(objects.surface); + } + mConnection->flush(); + } + + VkSurfaceKHR WaylandWindow::createSurface(VkInstance instance) const { + VkWaylandSurfaceCreateInfoKHR createInfo{ + .sType = VK_STRUCTURE_TYPE_WAYLAND_SURFACE_CREATE_INFO_KHR, + .display = mConnection->display(), + .surface = mObjects->surface, + }; + + VkSurfaceKHR surface = VK_NULL_HANDLE; + if (vkCreateWaylandSurfaceKHR(instance, &createInfo, nullptr, &surface) != VK_SUCCESS) { + return VK_NULL_HANDLE; + } + return surface; + } + + VkExtent2D WaylandWindow::framebufferExtent() const { + const Vec2F surface = mFrame.surfaceSize(); + return { scaledLength(surface.x, mScale), scaledLength(surface.y, mScale) }; + } + + void WaylandWindow::onTranslucencyResolved(bool translucent) { + mFrame.setTranslucent(translucent); + mGeometryDirty = true; + } + + void WaylandWindow::dispatchEvents(halnf timeoutMs) { + const bool waitsForever = timeoutMs < 0; + const time_ns deadline = get_time_ns() + (time_ns) (std::max(timeoutMs, 0.0f) * 1.0e6f); + + while (true) { + const halnf remainingMs = waitsForever ? -1.0f : (halnf) ((double) (deadline - get_time_ns()) / 1.0e6); + if (pollOnce(waitsForever ? -1 : std::max(0, (int) std::ceil(remainingMs)))) { + return; + } + if (!waitsForever && get_time_ns() >= deadline) { + return; + } + } + } + + void WaylandWindow::wake() { + if (mWakeDescriptor >= 0) { + eventfd_write(mWakeDescriptor, 1); + } + } + + bool WaylandWindow::pollOnce(int timeoutMs) { + wl_display* display = mConnection->display(); + + mConnection->flush(); + if (wl_display_dispatch_pending(display) > 0) { + return true; + } + + while (wl_display_prepare_read(display) != 0) { + if (wl_display_dispatch_pending(display) > 0) { + return true; + } + } + mConnection->flush(); + + std::vector descriptors = { + { wl_display_get_fd(display), POLLIN, 0 }, + { mWakeDescriptor, POLLIN, 0 }, + }; + mSeat->collectPollDescriptors(descriptors); + + const int ready = poll(descriptors.data(), (nfds_t) descriptors.size(), timeoutMs); + if (ready > 0 && (descriptors[0].revents & POLLIN)) { + if (wl_display_read_events(display) < 0) { + throw std::runtime_error("lost the Wayland connection"); + } + } else { + wl_display_cancel_read(display); + } + + bool handled = wl_display_dispatch_pending(display) > 0; + if (ready > 0 && (descriptors[1].revents & POLLIN)) { + eventfd_t ignored = 0; + eventfd_read(mWakeDescriptor, &ignored); + handled = true; + } + if (ready > 0) { + const std::vector seatDescriptors(descriptors.begin() + 2, descriptors.end()); + mSeat->onDescriptorsReady(seatDescriptors); + handled = handled || std::any_of(seatDescriptors.begin(), seatDescriptors.end(), [](const pollfd& entry) { + return entry.revents != 0; + }); + } + + mConnection->flush(); + return handled; + } + + void WaylandWindow::waitUntilConfigured() { + while (!mConfigured) { + if (!mConnection->dispatch(kConfigureWaitMs)) { + break; + } + applyPendingConfigure(); + } + } + + void WaylandWindow::applyPendingConfigure() { + if (!mPendingConfigure) { + return; + } + const WaylandConfigure configure = *mPendingConfigure; + mPendingConfigure.reset(); + + applyConfigure(configure); + xdg_surface_ack_configure(mObjects->shellSurface, configure.serial); + mConfigured = true; + notifyOwnerResized(); + } + + void WaylandWindow::applyConfigure(const WaylandConfigure& configure) { + mMaximized = configure.maximized || configure.fullscreen; + mFullScreen = configure.fullscreen; + mActivated = configure.activated; + + const bool tiled = mMaximized || configure.tiled; + if (tiled != mFrame.isTiled()) { + mFrame.setTiled(tiled); + mGeometryDirty = true; + } + + Vec2F contentSize = mFrame.contentSize(); + if (configure.width > 0 && configure.height > 0) { + contentSize = { (halnf) configure.width, (halnf) configure.height }; + } else if (!tiled) { + contentSize = mRestoredContentSize; + } + + contentSize.x = std::max(contentSize.x, kMinimumContentWidth); + contentSize.y = std::max(contentSize.y, kMinimumContentHeight); + if (!tiled) { + mRestoredContentSize = contentSize; + } + + if (contentSize != mFrame.contentSize()) { + mFrame.setContentSize(contentSize); + mGeometryDirty = true; + } + } + + void WaylandWindow::applyScale(halnf scale) { + if (scale <= 0 || scale == mScale) { + return; + } + + mScale = scale; + mGeometryDirty = true; + if (mConfigured) { + notifyOwnerResized(); + } + } + + void WaylandWindow::notifyOwnerResized() { mHost.onContentResized(); } + + void WaylandWindow::setAcceptsInput(bool accepts) { + mAcceptsInput = accepts; + mGeometryDirty = true; + updateFrameGeometry(); + wl_surface_commit(mObjects->surface); + mConnection->flush(); + } + + void WaylandWindow::commitFrameGeometry() { + if (mGeometryDirty) { + updateFrameGeometry(); + } + requestFrameCallback(); + } + + void WaylandWindow::requestFrameCallback() { + if (mObjects->frameCallback) { + return; + } + mObjects->frameCallback = wl_surface_frame(mObjects->surface); + wl_callback_add_listener(mObjects->frameCallback, &kFrameListener, this); + } + + bool WaylandWindow::isWaitingForFrame() const { return mObjects->frameCallback != nullptr; } + + void WaylandWindow::updateFrameGeometry() { + Objects& objects = *mObjects; + const Vec2F surface = mFrame.surfaceSize(); + const RectF content = mFrame.contentRect(); + const RectF input = mFrame.inputRect(); + + xdg_surface_set_window_geometry( + objects.shellSurface, (int32_t) content.x, (int32_t) content.y, (int32_t) content.z, (int32_t) content.w + ); + + wl_region* inputRegion = wl_compositor_create_region(mConnection->compositor()); + if (mAcceptsInput) { + wl_region_add(inputRegion, (int32_t) input.x, (int32_t) input.y, (int32_t) input.z, (int32_t) input.w); + } + wl_surface_set_input_region(objects.surface, inputRegion); + wl_region_destroy(inputRegion); + + if (!mFrame.hasShadow()) { + wl_region* opaqueRegion = wl_compositor_create_region(mConnection->compositor()); + wl_region_add(opaqueRegion, 0, 0, (int32_t) surface.x, (int32_t) surface.y); + wl_surface_set_opaque_region(objects.surface, opaqueRegion); + wl_region_destroy(opaqueRegion); + } else { + wl_surface_set_opaque_region(objects.surface, nullptr); + } + + if (objects.viewport) { + wp_viewport_set_destination(objects.viewport, (int32_t) surface.x, (int32_t) surface.y); + } else { + wl_surface_set_buffer_scale(objects.surface, (int32_t) std::lround(mScale)); + } + + mGeometryDirty = false; + } + + namespace { + struct ActivationToken { + std::string value; + bool done = false; + }; + + void activationTokenDone(void* data, xdg_activation_token_v1*, const char* token) { + auto& result = *static_cast(data); + result.value = token ? token : ""; + result.done = true; + } + + const xdg_activation_token_v1_listener kActivationTokenListener = { activationTokenDone }; + } + + std::string WaylandWindow::activationToken() { + xdg_activation_v1* activation = mConnection->activation(); + if (!activation) { + return {}; + } + + wl_display* display = mConnection->display(); + wl_event_queue* queue = wl_display_create_queue(display); + auto* wrapped = static_cast(wl_proxy_create_wrapper(activation)); + wl_proxy_set_queue(reinterpret_cast(wrapped), queue); + + ActivationToken result; + xdg_activation_token_v1* token = xdg_activation_v1_get_activation_token(wrapped); + xdg_activation_token_v1_add_listener(token, &kActivationTokenListener, &result); + if (mConnection->seat() && mSeat->lastInputSerial() != 0) { + xdg_activation_token_v1_set_serial(token, mSeat->lastInputSerial(), mConnection->seat()); + } + xdg_activation_token_v1_set_surface(token, mObjects->surface); + xdg_activation_token_v1_commit(token); + while (!result.done && wl_display_roundtrip_queue(display, queue) >= 0) { + } + + xdg_activation_token_v1_destroy(token); + wl_proxy_wrapper_destroy(wrapped); + wl_event_queue_destroy(queue); + return result.value; + } + + bool WaylandWindow::openUrl(const std::string& url) { + if (url.empty()) { + return false; + } + + std::vector environment; + for (char** entry = environ; *entry; entry++) { + const std::string variable = *entry; + if (variable.starts_with("XDG_ACTIVATION_TOKEN=") || variable.starts_with("DESKTOP_STARTUP_ID=")) { + continue; + } + environment.push_back(variable); + } + const std::string token = activationToken(); + if (!token.empty()) { + environment.push_back("XDG_ACTIVATION_TOKEN=" + token); + environment.push_back("DESKTOP_STARTUP_ID=" + token); + } + + std::vector environmentPointers; + for (std::string& variable : environment) { + environmentPointers.push_back(variable.data()); + } + environmentPointers.push_back(nullptr); + + std::string program = "xdg-open"; + std::string argument = url; + char* arguments[] = { program.data(), argument.data(), nullptr }; + + pid_t child = 0; + if (posix_spawnp(&child, program.c_str(), nullptr, nullptr, arguments, environmentPointers.data()) != 0) { + return false; + } + std::thread([child]() { waitpid(child, nullptr, 0); }).detach(); + return true; + } + + Vec2F WaylandWindow::toContent(Vec2F surfacePoint) const { + const RectF content = mFrame.contentRect(); + return { surfacePoint.x - content.x, surfacePoint.y - content.y }; + } + + void WaylandWindow::onPointerSurfaceMove(Vec2F surfacePoint) { + const bool contentHoldsPointer = mHeldButtons > 0; + const WindowEdge edge = contentHoldsPointer ? WindowEdge::None : mFrame.edgeAt(surfacePoint); + mHoveredEdge = edge; + + if (edge != WindowEdge::None) { + mSeat->setCursor(cursorForEdge(edge)); + if (mPointerOnContent) { + mPointerOnContent = false; + input().post(PointerLeft{}); + } + return; + } + + mSeat->setCursor(mClientCursor); + mPointerOnContent = true; + input().post(PointerMoved{ toContent(surfacePoint) }); + } + + // Touchpad pinches arrive at the pointer; the gesture is placed where the pointer is, in content coordinates. + void WaylandWindow::onPointerSurfacePinch(const TouchGesture& gesture) { + TouchGesture content = gesture; + content.center = toContent(gesture.center); + input().post(GestureChanged{ content }); + } + + void WaylandWindow::onPointerSurfaceLeft() { + mHeldButtons = 0; + mHoveredEdge = WindowEdge::None; + mPointerOnContent = false; + input().post(PointerLeft{}); + } + + void WaylandWindow::onPointerSurfaceButton(uint32_t linuxButton, bool pressed, uint32_t serial) { + if (pressed && mHoveredEdge != WindowEdge::None) { + if (linuxButton == BTN_LEFT) { + xdg_toplevel_resize(mObjects->toplevel, mConnection->seat(), serial, resizeEdgeCode(mHoveredEdge)); + } + return; + } + + const std::optional button = pointerButtonFromLinux(linuxButton); + if (!button) { + return; + } + + if (pressed) { + mHeldButtons++; + } else if (mHeldButtons > 0) { + mHeldButtons--; + } + input().post(PointerButtonChanged{ *button, pressed }); + } + + void WaylandWindow::minimize() { xdg_toplevel_set_minimized(mObjects->toplevel); } + + void WaylandWindow::toggleMaximize() { + if (mMaximized) { + xdg_toplevel_unset_maximized(mObjects->toplevel); + } else { + xdg_toplevel_set_maximized(mObjects->toplevel); + } + } + + void WaylandWindow::setFullScreen(bool fullScreen) { + if (fullScreen) { + xdg_toplevel_set_fullscreen(mObjects->toplevel, nullptr); + } else { + xdg_toplevel_unset_fullscreen(mObjects->toplevel); + } + } + + void WaylandWindow::beginMove() { + xdg_toplevel_move(mObjects->toplevel, mConnection->seat(), mSeat->lastInputSerial()); + } + + void WaylandWindow::beginResize(WindowEdge edge) { + if (edge == WindowEdge::None) { + return; + } + xdg_toplevel_resize(mObjects->toplevel, mConnection->seat(), mSeat->lastInputSerial(), resizeEdgeCode(edge)); + } + + void WaylandWindow::showWindowMenu(Vec2F contentPosition) { + const RectF content = mFrame.contentRect(); + xdg_toplevel_show_window_menu( + mObjects->toplevel, + mConnection->seat(), + mSeat->lastInputSerial(), + (int32_t) (contentPosition.x + content.x), + (int32_t) (contentPosition.y + content.y) + ); + } + + void WaylandWindow::setCursor(CursorShape shape) { + mClientCursor = shape; + if (mHoveredEdge == WindowEdge::None) { + mSeat->setCursor(shape); + } + } + + void WaylandWindow::setClipboardText(const std::string& text) { mSeat->setClipboardText(text); } + + std::string WaylandWindow::clipboardText() { return mSeat->clipboardText(); } + + ClipboardImage WaylandWindow::clipboardImage() { return mSeat->clipboardImage(); } + + void WaylandWindow::setTextInputEnabled(bool enabled) { mSeat->setTextInputEnabled(enabled); } + + void WaylandWindow::setTextInputRect(const RectF& contentRect) { + const RectF content = mFrame.contentRect(); + mSeat->setTextInputRect({ contentRect.x + content.x, contentRect.y + content.y, contentRect.z, contentRect.w }); + } + + PlatformInfo WaylandWindow::platformInfo() const { + PlatformInfo info; + info.system = OperatingSystem::Linux; + info.formFactor = FormFactor::Desktop; + info.contentScale = mScale; + info.screenSize = mFrame.contentSize(); + info.hasMouse = mSeat->hasPointer(); + info.hasTouch = mSeat->hasTouch(); + info.hasPen = mSeat->hasTablet(); + info.hasPhysicalKeyboard = mSeat->hasKeyboard(); + info.hasHoverPointer = info.hasMouse || info.hasPen; + return info; + } + + std::vector platformSurfaceExtensions() { + return { VK_KHR_SURFACE_EXTENSION_NAME, VK_KHR_WAYLAND_SURFACE_EXTENSION_NAME }; + } + + // Resources are installed next to the executable, so a moved or installed binary still finds them. + std::filesystem::path platformResourceDirectory() { + std::error_code error; + const std::filesystem::path executable = std::filesystem::read_symlink("/proc/self/exe", error); + if (error) { + return "rsc"; + } + return executable.parent_path() / "rsc"; + } + + std::unique_ptr createPlatformWindow( + PlatformWindowHost& host, Vec2F contentSize, const WindowIdentity& identity, const PlatformContext& + ) { + return std::make_unique(host, contentSize, identity); + } +} diff --git a/GraphicApplication/src/backends/wayland/private/WaylandWindow.hpp b/GraphicApplication/src/backends/wayland/private/WaylandWindow.hpp new file mode 100644 index 00000000..63d338fd --- /dev/null +++ b/GraphicApplication/src/backends/wayland/private/WaylandWindow.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include "DesktopWindowControls.hpp" +#include "PlatformWindow.hpp" +#include "WaylandSeatContext.hpp" + +#include +#include +#include +#include + +struct wl_display; + +namespace tp { + + class WaylandConnection; + class WaylandSeat; + + // One xdg_toplevel's pending configure; applied and acknowledged by the frame loop. + struct WaylandConfigure { + int32_t width = 0; + int32_t height = 0; + bool maximized = false; + bool fullscreen = false; + bool activated = false; + bool tiled = false; + uint32_t serial = 0; + }; + + class WaylandWindow final : public PlatformWindow, public DesktopWindowControls, public WaylandSurfacePointer { + public: + WaylandWindow(PlatformWindowHost& host, Vec2F contentSize, const WindowIdentity& identity); + ~WaylandWindow() override; + + WaylandWindow(const WaylandWindow&) = delete; + WaylandWindow& operator=(const WaylandWindow&) = delete; + + [[nodiscard]] VkSurfaceKHR createSurface(VkInstance instance) const override; + [[nodiscard]] VkExtent2D framebufferExtent() const override; + [[nodiscard]] bool wantsTranslucency() const override { return true; } + + void setAcceptsInput(bool accepts) override; + void onTranslucencyResolved(bool translucent) override; + + void dispatchEvents(halnf timeoutMs) override; + void wake() override; + void waitUntilConfigured() override; + [[nodiscard]] bool hasPendingConfigure() const override { return mPendingConfigure.has_value(); } + void applyPendingConfigure() override; + void commitFrameGeometry() override; + [[nodiscard]] bool isWaitingForFrame() const override; + + [[nodiscard]] bool shouldClose() const override { return mCloseRequested; } + void requestClose() override { mCloseRequested = true; } + + [[nodiscard]] const WindowFrame& frame() const override { return mFrame; } + [[nodiscard]] halnf scale() const override { return mScale; } + [[nodiscard]] bool isMaximized() const override { return mMaximized; } + [[nodiscard]] bool isFocused() const override { return mActivated; } + + void minimize() override; + [[nodiscard]] DesktopWindowControls* desktopControls() override { return this; } + void toggleMaximize() override; + void setFullScreen(bool fullScreen) override; + [[nodiscard]] bool isFullScreen() const override { return mFullScreen; } + void beginMove() override; + void beginResize(WindowEdge edge) override; + void showWindowMenu(Vec2F contentPosition) override; + + void setCursor(CursorShape shape) override; + void setClipboardText(const std::string& text) override; + [[nodiscard]] std::string clipboardText() override; + [[nodiscard]] ClipboardImage clipboardImage() override; + bool openUrl(const std::string& url) override; + void setTextInputEnabled(bool enabled) override; + void setTextInputRect(const RectF& contentRect) override; + + [[nodiscard]] PlatformInfo platformInfo() const override; + + private: + friend struct WaylandListeners; + + void applyConfigure(const WaylandConfigure& configure); + void applyScale(halnf scale); + void notifyOwnerResized(); + [[nodiscard]] bool pollOnce(int timeoutMs); + void updateFrameGeometry(); + void requestFrameCallback(); + + void onPointerSurfaceMove(Vec2F surfacePoint) override; + void onPointerSurfaceButton(uint32_t linuxButton, bool pressed, uint32_t serial) override; + void onPointerSurfaceLeft() override; + void onPointerSurfacePinch(const TouchGesture& gesture) override; + [[nodiscard]] InputSink& input() { return mHost.rawInput(); } + [[nodiscard]] Vec2F toContent(Vec2F surfacePoint) const; + [[nodiscard]] std::string activationToken(); + + PlatformWindowHost& mHost; + bool mAcceptsInput = true; + bool mPointerOnContent = false; + std::unique_ptr mConnection; + std::unique_ptr mSeat; + int mWakeDescriptor = -1; + + struct Objects; + std::unique_ptr mObjects; + + WindowFrame mFrame; + Vec2F mRestoredContentSize; + halnf mScale = 1; + bool mConfigured = false; + std::optional mPendingConfigure; + bool mCloseRequested = false; + bool mMaximized = false; + bool mFullScreen = false; + bool mActivated = false; + bool mGeometryDirty = true; + WindowEdge mHoveredEdge = WindowEdge::None; + int mHeldButtons = 0; + CursorShape mClientCursor = CursorShape::Default; + }; +} diff --git a/GraphicApplication/src/canvas/private/Canvas.cpp b/GraphicApplication/src/canvas/private/Canvas.cpp new file mode 100644 index 00000000..88008b93 --- /dev/null +++ b/GraphicApplication/src/canvas/private/Canvas.cpp @@ -0,0 +1,433 @@ +#include "CanvasContext.hpp" + +#include "GpuError.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); + } + + 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()); + + mOrigin = { 0, 0 }; + mClips.clear(); + mSavedFrames.clear(); +} + +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::restore() { + DEBUG_ASSERT(mSavedFrames.size()); + const SavedFrame saved = mSavedFrames.last(); + mSavedFrames.pop(); + + while (mClips.size() > saved.clipDepth) { + 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; + } + + 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; + if (mClips.size()) { + mClips.last().calcIntersection(placed, 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); +} + +bool Canvas::isClippedAway() const { return mClips.size() && mClips.last().size.length2() <= EPSILON; } + +bool Canvas::isOutsideClip(const RectF& rect) const { + if (!mClips.size()) { + return false; + } + const RectF placed = { rect.pos + mOrigin, rect.size }; + 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; +} + +void Canvas::text(const char* 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); +} + +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); +} + +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); +} + +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 glyphs(length); + const int count = nvgTextGlyphPositions(mContext->vg, 0, 0, string, string + 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 - string); + const halnf pen = glyphs[(size_t) index].x; + for (; byte <= glyphStart && byte < length; byte++) { + widths[byte] = pen; + } + } + const halnf total = nvgTextBounds(mContext->vg, 0, 0, string, string + length, nullptr); + for (; byte < length; byte++) { + widths[byte] = total; + } + widths[length] = total; +} + +void Canvas::drawImage(RectF rect, ImageHandle image, halnf angle, halnf alpha, halnf rounding) { + if (!image.isValid()) { + return; + } + 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 new file mode 100644 index 00000000..5dc527b9 --- /dev/null +++ b/GraphicApplication/src/canvas/private/CanvasContext.hpp @@ -0,0 +1,24 @@ +#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/CanvasFontLoader.cpp b/GraphicApplication/src/canvas/private/CanvasFontLoader.cpp new file mode 100644 index 00000000..482489a9 --- /dev/null +++ b/GraphicApplication/src/canvas/private/CanvasFontLoader.cpp @@ -0,0 +1,89 @@ +#include "CanvasFontLoader.hpp" +#include "CanvasFonts.hpp" + +#include + +#include +#include +#include +#include +#include + +using namespace tp; + +namespace { + bool isBold(FontFace face) { return face == FontFace::Bold || face == FontFace::BoldItalic; } + + class LoadedFallbackFonts { + public: + LoadedFallbackFonts(NVGcontext* vg, const std::filesystem::path& resourceDirectory) { + for (const FontFile& font : kFallbackFonts) { + const std::string path = (resourceDirectory / font.path).string(); + mLoaded.emplace_back(font.name, nvgCreateFont(vg, font.name, path.c_str())); + } + } + + template + [[nodiscard]] std::vector idsInChainOrder(const std::array& chain) const { + std::vector ids; + for (const FontFile& font : chain) { + const int id = idOf(font.name); + if (id != -1) { + ids.push_back(id); + } + } + return ids; + } + + private: + [[nodiscard]] int idOf(const char* name) const { + for (const auto& [loaded, id] : mLoaded) { + if (std::strcmp(loaded, name) == 0) { + return id; + } + } + return -1; + } + + std::vector> mLoaded; + }; + + int loadTextFont( + NVGcontext* vg, + const FontFile& font, + const std::vector& fallbacks, + int missing, + const std::filesystem::path& resourceDirectory + ) { + const std::string path = (resourceDirectory / font.path).string(); + const int id = nvgCreateFont(vg, font.name, path.c_str()); + if (id == -1) { + return missing; + } + for (const int fallback : fallbacks) { + nvgAddFallbackFontId(vg, id, fallback); + } + return id; + } +} + +int CanvasFontIds::idFor(FontFace face) const { + if (face == FontFace::Monospace) { + return monospace; + } + return isBold(face) ? bold : regular; +} + +CanvasFontIds tp::loadCanvasFonts(NVGcontext* vg, const std::filesystem::path& resourceDirectory) { + const LoadedFallbackFonts fallbacks(vg, resourceDirectory); + + CanvasFontIds fonts; + fonts.regular = loadTextFont(vg, kRegularFont, fallbacks.idsInChainOrder(kRegularFallbacks), -1, resourceDirectory); + if (fonts.regular == -1) { + throw std::runtime_error("cannot load " + (resourceDirectory / kRegularFont.path).string()); + } + fonts.bold = loadTextFont(vg, kBoldFont, fallbacks.idsInChainOrder(kBoldFallbacks), fonts.regular, resourceDirectory); + fonts.monospace = + loadTextFont(vg, kMonospaceFont, fallbacks.idsInChainOrder(kMonospaceFallbacks), fonts.regular, resourceDirectory); + return fonts; +} diff --git a/GraphicApplication/src/canvas/private/CanvasFontLoader.hpp b/GraphicApplication/src/canvas/private/CanvasFontLoader.hpp new file mode 100644 index 00000000..30b82a79 --- /dev/null +++ b/GraphicApplication/src/canvas/private/CanvasFontLoader.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "Canvas.hpp" + +#include + +struct NVGcontext; + +namespace tp { + + struct CanvasFontIds { + int regular = -1; + int bold = -1; + int monospace = -1; + + [[nodiscard]] int idFor(FontFace face) const; + }; + + CanvasFontIds loadCanvasFonts(NVGcontext* vg, const std::filesystem::path& resourceDirectory); +} diff --git a/GraphicApplication/src/canvas/private/CanvasFonts.hpp b/GraphicApplication/src/canvas/private/CanvasFonts.hpp new file mode 100644 index 00000000..2cb2f625 --- /dev/null +++ b/GraphicApplication/src/canvas/private/CanvasFonts.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +namespace tp { + + struct FontFile { + const char* name; + const char* path; + }; + + inline constexpr FontFile kRegularFont = { "default", "Font.ttf" }; + inline constexpr FontFile kBoldFont = { "bold", "FontBold.ttf" }; + inline constexpr FontFile kMonospaceFont = { "monospace", "FontMono.ttf" }; + + inline constexpr std::array kTextFonts = { kRegularFont, kBoldFont, kMonospaceFont }; + + inline constexpr FontFile kEmojiFont = { "emoji", "Emoji.ttf" }; + inline constexpr FontFile kSymbolsFont = { "symbols", "FontSymbols.ttf" }; + inline constexpr FontFile kMathFont = { "math", "FontMath.ttf" }; + inline constexpr FontFile kSpecialsFont = { "specials", "FontSpecials.ttf" }; + + inline constexpr FontFile kCyrillicRegularFont = { "cyrillic", "FontCyrillic.ttf" }; + inline constexpr FontFile kCyrillicBoldFont = { "cyrillic-bold", "FontCyrillicBold.ttf" }; + + inline constexpr std::array kFallbackFonts = { + kEmojiFont, kSymbolsFont, kMathFont, kSpecialsFont, kCyrillicRegularFont, kCyrillicBoldFont, + }; + + inline constexpr std::array kSymbolFallbacksFirst = { kEmojiFont, kSymbolsFont, kMathFont, kSpecialsFont }; + + template + constexpr std::array + symbolsThenCyrillic(const std::array& symbols, const FontFile& cyrillicOfFaceWeight) { + std::array chain{}; + for (std::size_t index = 0; index < Size; index++) { + chain[index] = symbols[index]; + } + chain[Size] = cyrillicOfFaceWeight; + return chain; + } + + inline constexpr std::array kRegularFallbacks = symbolsThenCyrillic(kSymbolFallbacksFirst, kCyrillicRegularFont); + inline constexpr std::array kBoldFallbacks = symbolsThenCyrillic(kSymbolFallbacksFirst, kCyrillicBoldFont); + inline constexpr std::array kMonospaceFallbacks = symbolsThenCyrillic(kSymbolFallbacksFirst, kCyrillicRegularFont); +} diff --git a/GraphicApplication/src/canvas/private/CanvasImages.cpp b/GraphicApplication/src/canvas/private/CanvasImages.cpp new file mode 100644 index 00000000..f5e88bc6 --- /dev/null +++ b/GraphicApplication/src/canvas/private/CanvasImages.cpp @@ -0,0 +1,70 @@ +#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 new file mode 100644 index 00000000..00110671 --- /dev/null +++ b/GraphicApplication/src/canvas/public/Canvas.hpp @@ -0,0 +1,183 @@ +#pragma once + +#include "Color.hpp" +#include "Rect.hpp" +#include "WindowFrame.hpp" +#include "WindowSurface.hpp" + +#include "Buffer.hpp" + +#include +#include + +namespace tp { + + class Profiler; + + 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 { + public: + ImageHandle() = default; + + [[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: + Canvas(WindowSurface& surface, Profiler& profiler, const std::filesystem::path& resourceDirectory); + ~Canvas(); + + Canvas(const Canvas&) = delete; + Canvas& operator=(const Canvas&) = delete; + + void drawBegin(const GpuFrame& frame, const WindowFrame& windowFrame); + void drawEnd(); + + 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; + }; + + // Restores origin, clipping and composite operation when the scope ends. + class SavedState { + public: + explicit SavedState(Canvas& canvas) : + mCanvas(canvas) { + mCanvas.save(); + } + + ~SavedState() { mCanvas.restore(); } + + SavedState(const SavedState&) = delete; + SavedState& operator=(const SavedState&) = delete; + + protected: + Canvas& mCanvas; + }; + + // Moves the origin by an offset from the current origin until destroyed. + class Translation : public SavedState { + public: + Translation(Canvas& canvas, Vec2F offset) : + SavedState(canvas) { + mCanvas.translateOrigin(offset); + } + }; + + // Places the origin at a point relative to the current origin and clips to an area there, until destroyed. + class Placement : public SavedState { + public: + Placement(Canvas& canvas, Vec2F origin, Vec2F clipSize) : + SavedState(canvas) { + mCanvas.translateOrigin(origin); + mCanvas.clipTo({ { 0, 0 }, clipSize }); + } + }; + + [[nodiscard]] Vec2F origin() const { return mOrigin; } + [[nodiscard]] bool isClippedAway() const; + [[nodiscard]] bool isOutsideClip(const RectF& rect) const; + + void setCompositeOperation(CompositeOperation operation); + + void beginPath(); + void addPathRect(RectF rect, halnf round = 0); + void setPathWinding(PathWinding winding); + void fillPath(const RGBA& color); + 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 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 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); + + [[nodiscard]] ImageHandle createImageFromRenderTarget(uint32_t renderTargetId); + void updateImageFromRenderTarget(ImageHandle image, uint32_t renderTargetId); + [[nodiscard]] ImageHandle createImageFromFile(const char* path); + [[nodiscard]] ImageHandle createImageFromMemoryRGBA(int width, int height, const unsigned char* rgba); + void updateImageRGBA(ImageHandle image, const unsigned char* rgba); + [[nodiscard]] Vec2F imageSize(ImageHandle image); + void deleteImage(ImageHandle image); + void drawImage(RectF rect, ImageHandle image, halnf angle = 0, halnf alpha = 1, halnf rounding = 0); + + private: + struct SavedFrame { + Vec2F origin; + size_t clipDepth = 0; + }; + + void save(); + void restore(); + void translateOrigin(Vec2F offset) { mOrigin += offset; } + void clipTo(const RectF& rect); + void applyClip(); + + std::unique_ptr mContext; + Buffer mClips; + Buffer mSavedFrames; + Vec2F mOrigin = { 0, 0 }; + }; +} diff --git a/GraphicApplication/src/debug_ui/private/DebugGUI.cpp b/GraphicApplication/src/debug_ui/private/DebugGUI.cpp new file mode 100644 index 00000000..79531f28 --- /dev/null +++ b/GraphicApplication/src/debug_ui/private/DebugGUI.cpp @@ -0,0 +1,126 @@ +#include "DebugGUI.hpp" + +#include "GpuError.hpp" +#include "ImGuiPlatform.hpp" + +#include +#include + +#include "implot.h" + +#include + +namespace tp { + namespace { + constexpr float kFontSize = 18.f; + constexpr float kHeadlessFrameSeconds = 1.0f / 60.0f; + + VkDescriptorPool createDescriptorPool(VkDevice device) { + const std::array poolSizes = { + VkDescriptorPoolSize{ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 64 }, + }; + const VkDescriptorPoolCreateInfo poolInfo{ + .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO, + .flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, + .maxSets = 64, + .poolSizeCount = (uint32_t) poolSizes.size(), + .pPoolSizes = poolSizes.data(), + }; + VkDescriptorPool pool = VK_NULL_HANDLE; + requireSuccess(vkCreateDescriptorPool(device, &poolInfo, nullptr, &pool), "vkCreateDescriptorPool"); + return pool; + } + } + + class DebugGUI::Context { + public: + Context(Window& window, DebugInterface mode) : + window(window), + mode(mode) {} + + Window& window; + DebugInterface mode; + ImGuiContext* imgui = nullptr; + ImPlotContext* implot = nullptr; + VkDescriptorPool descriptorPool = VK_NULL_HANDLE; + std::unique_ptr platform; + + [[nodiscard]] bool isVisible() const { return mode == DebugInterface::Visible; } + }; +} + +using namespace tp; + +DebugGUI::DebugGUI(Window& window, const std::filesystem::path& resourceDirectory, DebugInterface mode) : + mContext(std::make_unique(window, mode)) { + IMGUI_CHECKVERSION(); + mContext->imgui = ImGui::CreateContext(); + mContext->implot = ImPlot::CreateContext(); + + ImGuiIO& io = ImGui::GetIO(); + io.ConfigInputTrickleEventQueue = false; + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + io.IniFilename = nullptr; + const std::string fontPath = (resourceDirectory / "Font.ttf").string(); + io.Fonts->AddFontFromFileTTF(fontPath.c_str(), kFontSize); + + if (!mContext->isVisible()) { + io.Fonts->Build(); + return; + } + + WindowSurface& surface = window.surface(); + const GpuDevice& gpu = surface.gpu(); + mContext->platform = std::make_unique(window); + mContext->descriptorPool = createDescriptorPool(gpu.device()); + + ImGui_ImplVulkan_InitInfo initInfo{}; + initInfo.Instance = gpu.instance(); + initInfo.PhysicalDevice = gpu.physicalDevice(); + initInfo.Device = gpu.device(); + initInfo.QueueFamily = gpu.graphicsQueueFamily(); + initInfo.Queue = gpu.graphicsQueue(); + initInfo.DescriptorPool = mContext->descriptorPool; + initInfo.RenderPass = surface.renderPass(); + initInfo.MinImageCount = WindowSurface::kFramesInFlight; + initInfo.ImageCount = std::max(surface.imageCount(), WindowSurface::kFramesInFlight); + initInfo.MSAASamples = VK_SAMPLE_COUNT_1_BIT; + ImGui_ImplVulkan_Init(&initInfo); +} + +DebugGUI::~DebugGUI() { + if (mContext->isVisible()) { + mContext->window.surface().gpu().waitIdle(); + ImGui_ImplVulkan_Shutdown(); + mContext->platform.reset(); + vkDestroyDescriptorPool(mContext->window.surface().gpu().device(), mContext->descriptorPool, nullptr); + } + ImPlot::DestroyContext(mContext->implot); + ImGui::DestroyContext(mContext->imgui); +} + +DebugInterface DebugGUI::mode() const { return mContext->mode; } + +void DebugGUI::drawBegin() { + if (mContext->isVisible()) { + ImGui_ImplVulkan_NewFrame(); + mContext->platform->newFrame(); + } else { + const Vec2F size = mContext->window.contentSize(); + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(std::max(size.x, 1.0f), std::max(size.y, 1.0f)); + io.DeltaTime = kHeadlessFrameSeconds; + } + ImGui::NewFrame(); +} + +void DebugGUI::drawEnd(const GpuFrame& frame) { + ImGui::Render(); + if (!mContext->isVisible()) { + return; + } + mContext->platform->endFrame(); + ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), frame.commandBuffer); +} + +void DebugGUI::showValue(const char* label, halnf value) { ImGui::Text("%s: %f", label, value); } diff --git a/GraphicApplication/src/debug_ui/private/ImGuiPlatform.cpp b/GraphicApplication/src/debug_ui/private/ImGuiPlatform.cpp new file mode 100644 index 00000000..6dae034c --- /dev/null +++ b/GraphicApplication/src/debug_ui/private/ImGuiPlatform.cpp @@ -0,0 +1,280 @@ +#include "ImGuiPlatform.hpp" + +#include "Timing.hpp" +#include "Window.hpp" + +#include + +#include +#include + +using namespace tp; + +namespace { + constexpr double kFirstFrameSeconds = 1.0 / 60.0; + + ImGuiKey imguiKey(Key key) { + static const std::unordered_map keys = { + { Key::Tab, ImGuiKey_Tab }, + { Key::Left, ImGuiKey_LeftArrow }, + { Key::Right, ImGuiKey_RightArrow }, + { Key::Up, ImGuiKey_UpArrow }, + { Key::Down, ImGuiKey_DownArrow }, + { Key::PageUp, ImGuiKey_PageUp }, + { Key::PageDown, ImGuiKey_PageDown }, + { Key::Home, ImGuiKey_Home }, + { Key::End, ImGuiKey_End }, + { Key::Insert, ImGuiKey_Insert }, + { Key::Delete, ImGuiKey_Delete }, + { Key::Backspace, ImGuiKey_Backspace }, + { Key::Space, ImGuiKey_Space }, + { Key::Enter, ImGuiKey_Enter }, + { Key::Escape, ImGuiKey_Escape }, + { Key::LeftControl, ImGuiKey_LeftCtrl }, + { Key::LeftShift, ImGuiKey_LeftShift }, + { Key::LeftAlt, ImGuiKey_LeftAlt }, + { Key::LeftSuper, ImGuiKey_LeftSuper }, + { Key::RightControl, ImGuiKey_RightCtrl }, + { Key::RightShift, ImGuiKey_RightShift }, + { Key::RightAlt, ImGuiKey_RightAlt }, + { Key::RightSuper, ImGuiKey_RightSuper }, + { Key::Menu, ImGuiKey_Menu }, + { Key::Apostrophe, ImGuiKey_Apostrophe }, + { Key::Comma, ImGuiKey_Comma }, + { Key::Minus, ImGuiKey_Minus }, + { Key::Period, ImGuiKey_Period }, + { Key::Slash, ImGuiKey_Slash }, + { Key::Semicolon, ImGuiKey_Semicolon }, + { Key::Equal, ImGuiKey_Equal }, + { Key::LeftBracket, ImGuiKey_LeftBracket }, + { Key::Backslash, ImGuiKey_Backslash }, + { Key::RightBracket, ImGuiKey_RightBracket }, + { Key::GraveAccent, ImGuiKey_GraveAccent }, + { Key::CapsLock, ImGuiKey_CapsLock }, + { Key::ScrollLock, ImGuiKey_ScrollLock }, + { Key::NumLock, ImGuiKey_NumLock }, + { Key::PrintScreen, ImGuiKey_PrintScreen }, + { Key::Pause, ImGuiKey_Pause }, + { Key::KeypadDecimal, ImGuiKey_KeypadDecimal }, + { Key::KeypadDivide, ImGuiKey_KeypadDivide }, + { Key::KeypadMultiply, ImGuiKey_KeypadMultiply }, + { Key::KeypadSubtract, ImGuiKey_KeypadSubtract }, + { Key::KeypadAdd, ImGuiKey_KeypadAdd }, + { Key::KeypadEnter, ImGuiKey_KeypadEnter }, + { Key::KeypadEqual, ImGuiKey_KeypadEqual }, + }; + + const auto found = keys.find(key); + if (found != keys.end()) { + return found->second; + } + + const auto code = (int) key; + if (code >= (int) Key::A && code <= (int) Key::Z) { + return (ImGuiKey) (ImGuiKey_A + code - (int) Key::A); + } + if (code >= (int) Key::Digit0 && code <= (int) Key::Digit9) { + return (ImGuiKey) (ImGuiKey_0 + code - (int) Key::Digit0); + } + if (code >= (int) Key::F1 && code <= (int) Key::F24) { + return (ImGuiKey) (ImGuiKey_F1 + code - (int) Key::F1); + } + if (code >= (int) Key::Keypad0 && code <= (int) Key::Keypad9) { + return (ImGuiKey) (ImGuiKey_Keypad0 + code - (int) Key::Keypad0); + } + return ImGuiKey_None; + } + + ImGuiKey imguiModifier(Key key) { + switch (key) { + case Key::LeftControl: + case Key::RightControl: return ImGuiMod_Ctrl; + case Key::LeftShift: + case Key::RightShift: return ImGuiMod_Shift; + case Key::LeftAlt: + case Key::RightAlt: return ImGuiMod_Alt; + case Key::LeftSuper: + case Key::RightSuper: return ImGuiMod_Super; + default: return ImGuiKey_None; + } + } + + bool isRightModifier(Key key) { + return key == Key::RightControl || key == Key::RightShift || key == Key::RightAlt || key == Key::RightSuper; + } + + int modifierIndex(ImGuiKey modifier) { + switch (modifier) { + case ImGuiMod_Ctrl: return 0; + case ImGuiMod_Shift: return 1; + case ImGuiMod_Alt: return 2; + default: return 3; + } + } + + int imguiMouseButton(PointerButton button) { + switch (button) { + case PointerButton::Left: return ImGuiMouseButton_Left; + case PointerButton::Right: return ImGuiMouseButton_Right; + case PointerButton::Middle: return ImGuiMouseButton_Middle; + case PointerButton::Back: return 3; + case PointerButton::Forward: return 4; + } + return -1; + } + + CursorShape cursorShape(ImGuiMouseCursor cursor) { + switch (cursor) { + case ImGuiMouseCursor_TextInput: return CursorShape::Text; + case ImGuiMouseCursor_ResizeAll: return CursorShape::Move; + case ImGuiMouseCursor_ResizeNS: return CursorShape::ResizeVertical; + case ImGuiMouseCursor_ResizeEW: return CursorShape::ResizeHorizontal; + case ImGuiMouseCursor_ResizeNESW: return CursorShape::ResizeNorthEastSouthWest; + case ImGuiMouseCursor_ResizeNWSE: return CursorShape::ResizeNorthWestSouthEast; + case ImGuiMouseCursor_Hand: return CursorShape::Pointer; + case ImGuiMouseCursor_NotAllowed: return CursorShape::NotAllowed; + default: return CursorShape::Default; + } + } +} + +ImGuiPlatform::ImGuiPlatform(Window& window) : + mWindow(window) { + ImGuiIO& io = ImGui::GetIO(); + io.BackendPlatformName = "graphic_application"; + io.BackendPlatformUserData = this; + io.BackendFlags |= ImGuiBackendFlags_HasMouseCursors; + io.SetClipboardTextFn = &ImGuiPlatform::writeClipboard; + io.GetClipboardTextFn = &ImGuiPlatform::readClipboard; + io.ClipboardUserData = this; + io.SetPlatformImeDataFn = &ImGuiPlatform::placeInputMethod; +} + +ImGuiPlatform::~ImGuiPlatform() { + ImGuiIO& io = ImGui::GetIO(); + io.BackendPlatformName = nullptr; + io.BackendPlatformUserData = nullptr; + io.SetClipboardTextFn = nullptr; + io.GetClipboardTextFn = nullptr; + io.SetPlatformImeDataFn = nullptr; +} + +void ImGuiPlatform::newFrame() { + consumeInput(mWindow.input().state()); + + ImGuiIO& io = ImGui::GetIO(); + const Vec2F size = mWindow.contentSize(); + const Vec2F framebuffer = mWindow.framebufferSize(); + const Vec2F surface = mWindow.frame().surfaceSize(); + const halnf scale = surface.x > 0 ? framebuffer.x / surface.x : 1; + + io.DisplaySize = ImVec2(size.x, size.y); + io.DisplayFramebufferScale = ImVec2(scale, scale); + + const double now = (double) get_time_ns() / 1.0e9; + io.DeltaTime = mPreviousTime > 0 && now > mPreviousTime ? (float) (now - mPreviousTime) : (float) kFirstFrameSeconds; + mPreviousTime = now; + + updateCursor(); +} + +void ImGuiPlatform::endFrame() { + ImDrawData* drawData = ImGui::GetDrawData(); + if (!drawData) { + return; + } + + const halnf margin = mWindow.frame().margin(); + drawData->DisplayPos = ImVec2(-margin, -margin); + drawData->DisplaySize = ImVec2(mWindow.frame().surfaceSize().x, mWindow.frame().surfaceSize().y); + + const bool wantsText = ImGui::GetIO().WantTextInput; + if (wantsText != mTextInputWanted) { + mTextInputWanted = wantsText; + mWindow.services().setTextInputEnabled(wantsText); + } +} + +void ImGuiPlatform::consumeInput(const InputState& input) { + ImGuiIO& io = ImGui::GetIO(); + for (const InputEvent& event : input.events()) { + std::visit( + InputEventVisitor{ + [&io](const PointerMoved& moved) { io.AddMousePosEvent(moved.position.x, moved.position.y); }, + [&io](const PointerLeft&) { io.AddMousePosEvent(-FLT_MAX, -FLT_MAX); }, + [&io](const PointerButtonChanged& changed) { + io.AddMouseButtonEvent(imguiMouseButton(changed.button), changed.pressed); + }, + [&io](const Scrolled& scrolled) { io.AddMouseWheelEvent(scrolled.delta.x, scrolled.delta.y); }, + [this](const KeyChanged& changed) { onKey(changed.key, changed.action != KeyAction::Release); }, + [&io](const TextEntered& text) { io.AddInputCharactersUTF8(text.utf8.c_str()); }, + [&io](const KeyboardFocusChanged& focus) { io.AddFocusEvent(focus.focused); }, + [](const PointerSampled&) {}, + [](const ScrollEnded&) {}, + [](const GestureChanged&) {}, + }, + event + ); + } +} + +void ImGuiPlatform::onKey(Key key, bool pressed) { + updateModifier(key, pressed); + + const ImGuiKey imgui = imguiKey(key); + if (imgui != ImGuiKey_None) { + ImGui::GetIO().AddKeyEvent(imgui, pressed); + } +} + +void ImGuiPlatform::updateModifier(Key key, bool pressed) { + const ImGuiKey modifier = imguiModifier(key); + if (modifier == ImGuiKey_None) { + return; + } + + const size_t side = isRightModifier(key) ? 1 : 0; + mModifierSides[(size_t) modifierIndex(modifier)][side] = pressed; + + const bool down = + mModifierSides[(size_t) modifierIndex(modifier)][0] || mModifierSides[(size_t) modifierIndex(modifier)][1]; + ImGui::GetIO().AddKeyEvent(modifier, down); +} + +void ImGuiPlatform::updateCursor() { + ImGuiIO& io = ImGui::GetIO(); + if (io.ConfigFlags & ImGuiConfigFlags_NoMouseCursorChange) { + return; + } + if (!io.WantCaptureMouse) { + mCursor = -2; + return; + } + + const ImGuiMouseCursor cursor = ImGui::GetMouseCursor(); + if (cursor == mCursor) { + return; + } + + mCursor = cursor; + mWindow.services().setCursor(cursorShape(cursor)); +} + +const char* ImGuiPlatform::readClipboard(void* userData) { + auto* self = static_cast(userData); + self->mClipboard = self->mWindow.services().clipboardText(); + return self->mClipboard.c_str(); +} + +void ImGuiPlatform::writeClipboard(void* userData, const char* text) { + static_cast(userData)->mWindow.services().setClipboardText(text ? text : ""); +} + +void ImGuiPlatform::placeInputMethod(ImGuiViewport*, ImGuiPlatformImeData* data) { + auto* self = static_cast(ImGui::GetIO().BackendPlatformUserData); + if (!self || !data->WantVisible) { + return; + } + + self->mWindow.services().setTextInputRect({ data->InputPos.x, data->InputPos.y, 1, data->InputLineHeight }); +} diff --git a/GraphicApplication/src/debug_ui/private/ImGuiPlatform.hpp b/GraphicApplication/src/debug_ui/private/ImGuiPlatform.hpp new file mode 100644 index 00000000..eab61d09 --- /dev/null +++ b/GraphicApplication/src/debug_ui/private/ImGuiPlatform.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "InputState.hpp" + +#include + +struct ImGuiViewport; +struct ImGuiPlatformImeData; + +namespace tp { + + class Window; + + class ImGuiPlatform { + public: + explicit ImGuiPlatform(Window& window); + ~ImGuiPlatform(); + + ImGuiPlatform(const ImGuiPlatform&) = delete; + ImGuiPlatform& operator=(const ImGuiPlatform&) = delete; + + void newFrame(); + void endFrame(); + + private: + void consumeInput(const InputState& input); + void onKey(Key key, bool pressed); + + static const char* readClipboard(void* userData); + static void writeClipboard(void* userData, const char* text); + static void placeInputMethod(ImGuiViewport* viewport, ImGuiPlatformImeData* data); + + void updateCursor(); + void updateModifier(Key key, bool pressed); + + Window& mWindow; + std::string mClipboard; + double mPreviousTime = 0; + int mCursor = -2; + bool mModifierSides[4][2] = {}; + bool mTextInputWanted = false; + }; +} diff --git a/GraphicApplication/src/debug_ui/public/DebugGUI.hpp b/GraphicApplication/src/debug_ui/public/DebugGUI.hpp new file mode 100644 index 00000000..3b7f1be9 --- /dev/null +++ b/GraphicApplication/src/debug_ui/public/DebugGUI.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "Window.hpp" + +#include +#include + +namespace tp { + + // Visible renders ImGui into the window and feeds it input; Headless keeps the ImGui context and frames alive + // for code that calls ImGui directly, without drawing or taking input. + enum class DebugInterface { Visible, Headless }; + + // Owns the process ImGui and ImPlot contexts. ImGui calls are valid between drawBegin and drawEnd. + class DebugGUI { + class Context; + + public: + DebugGUI(Window& window, const std::filesystem::path& resourceDirectory, DebugInterface mode); + ~DebugGUI(); + + DebugGUI(const DebugGUI&) = delete; + DebugGUI& operator=(const DebugGUI&) = delete; + + [[nodiscard]] DebugInterface mode() const; + + void drawBegin(); + void drawEnd(const GpuFrame& frame); + + void showValue(const char* label, halnf value); + + private: + std::unique_ptr mContext; + }; +} diff --git a/GraphicApplication/src/decorations/private/WindowDecorations.cpp b/GraphicApplication/src/decorations/private/WindowDecorations.cpp new file mode 100644 index 00000000..5ef9e256 --- /dev/null +++ b/GraphicApplication/src/decorations/private/WindowDecorations.cpp @@ -0,0 +1,55 @@ +#include "WindowDecorations.hpp" + +using namespace tp; + +namespace { + const RGBA kTransparent = { 0, 0, 0, 0 }; + const RGBA kOpaque = { 0, 0, 0, 1 }; + + RectF contentArea(const WindowFrame& frame) { return { { 0, 0 }, frame.contentSize() }; } +} + +void WindowDecorations::paintBase(Canvas& canvas, const WindowFrame& frame) const { + canvas.rect(contentArea(frame), mStyle.base); +} + +void WindowDecorations::paintFrame(Canvas& canvas, const WindowFrame& frame, bool focused) const { + if (!frame.hasShadow()) { + return; + } + + const Canvas::SavedState saved(canvas); + cutCorners(canvas, frame); + paintOutline(canvas, frame); + paintShadow(canvas, frame, focused); +} + +void WindowDecorations::cutCorners(Canvas& canvas, const WindowFrame& frame) const { + canvas.setCompositeOperation(Canvas::CompositeOperation::DestinationOut); + canvas.beginPath(); + canvas.addPathRect(contentArea(frame)); + canvas.addPathRect(contentArea(frame), frame.cornerRadius()); + canvas.setPathWinding(Canvas::PathWinding::Hole); + canvas.fillPath(kOpaque); +} + +void WindowDecorations::paintOutline(Canvas& canvas, const WindowFrame& frame) const { + canvas.setCompositeOperation(Canvas::CompositeOperation::SourceOver); + canvas.outline(contentArea(frame), mStyle.outline, frame.cornerRadius(), mStyle.outlineWidth); +} + +void WindowDecorations::paintShadow(Canvas& canvas, const WindowFrame& frame, bool focused) const { + const halnf margin = frame.margin(); + const Canvas::BoxGradient shadow = { + .box = RectF(Vec2F(0.0f, mStyle.shadowOffsetY), frame.contentSize()), + .radius = frame.cornerRadius(), + .feather = margin * mStyle.shadowFeatherFraction, + .innerColor = focused ? mStyle.focusedShadow : mStyle.unfocusedShadow, + .outerColor = kTransparent, + }; + + canvas.setCompositeOperation(Canvas::CompositeOperation::DestinationOver); + canvas.beginPath(); + canvas.addPathRect({ { -margin, -margin }, frame.surfaceSize() }); + canvas.fillPath(shadow); +} diff --git a/GraphicApplication/src/decorations/public/WindowDecorations.hpp b/GraphicApplication/src/decorations/public/WindowDecorations.hpp new file mode 100644 index 00000000..e6ab1a2f --- /dev/null +++ b/GraphicApplication/src/decorations/public/WindowDecorations.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "Canvas.hpp" +#include "WindowFrame.hpp" + +namespace tp { + + struct WindowDecorationStyle { + RGBA base = { 0.118f, 0.122f, 0.133f, 1.0f }; + RGBA outline = { 1.0f, 1.0f, 1.0f, 0.07f }; + RGBA focusedShadow = { 0, 0, 0, 0.42f }; + RGBA unfocusedShadow = { 0, 0, 0, 0.24f }; + halnf shadowFeatherFraction = 0.9f; + halnf shadowOffsetY = 3; + halnf outlineWidth = 1; + }; + + // Client-side decorations of a translucent desktop window: base, rounded corners, outline and drop shadow. + // Geometry and edge hit-testing come from WindowFrame, which the backend uses for input; this paints the same frame. + class WindowDecorations { + public: + WindowDecorations() = default; + explicit WindowDecorations(const WindowDecorationStyle& style) : + mStyle(style) {} + + void paintBase(Canvas& canvas, const WindowFrame& frame) const; + void paintFrame(Canvas& canvas, const WindowFrame& frame, bool focused) const; + + private: + void cutCorners(Canvas& canvas, const WindowFrame& frame) const; + void paintOutline(Canvas& canvas, const WindowFrame& frame) const; + void paintShadow(Canvas& canvas, const WindowFrame& frame, bool focused) const; + + WindowDecorationStyle mStyle; + }; +} diff --git a/GraphicApplication/src/diagnostics/private/HangWatchdog.cpp b/GraphicApplication/src/diagnostics/private/HangWatchdog.cpp new file mode 100644 index 00000000..d28e2520 --- /dev/null +++ b/GraphicApplication/src/diagnostics/private/HangWatchdog.cpp @@ -0,0 +1,393 @@ +#include "HangWatchdog.hpp" + +#include "StackTrace.hpp" + +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#endif + +namespace tp { + + namespace { + int64_t nowNs() { + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) + .count(); + } + + std::string timestamp(const char* pattern) { + const std::time_t now = std::time(nullptr); + char text[64]; + std::strftime(text, sizeof(text), pattern, std::localtime(&now)); + return text; + } + + void writeFile(const std::filesystem::path& path, const std::string& text, std::ios::openmode mode) { + std::ofstream file(path, mode); + file << text; + } + + constexpr double kCheckIntervalMs = 100; + } + + HangWatchdog::HangWatchdog(HangSettings settings, UiTrail& trail) : + mSettings(std::move(settings)), + mTrail(trail) { + mSettings.directory = directoryFor(mSettings.directory.empty() ? defaultHangsDirectory() : mSettings.directory); + if (!mSettings.clockNs) { + mSettings.clockNs = nowNs; + } + mPhaseStartNs.store(now()); + allowDebuggerAttach(); + StackTrace::prepareThreadSampling(); + mUiTid = StackTrace::callingThreadId(); + mTrail.trackCallingThread(); + pruneOldReports(); + mThread = std::thread([this]() { watch(); }); + } + + HangWatchdog::~HangWatchdog() { + { + std::lock_guard lock(mStopMutex); + mStopping = true; + } + mStopped.notify_all(); + mThread.join(); + mTrail.stopTracking(); + } + + std::string HangWatchdog::formatThreads(const std::vector& threads, int uiTid) { + std::ostringstream text; + for (const ThreadSample& thread : threads) { + text << (thread.tid == uiTid ? "UI THREAD " : "thread ") << thread.tid << " \"" << thread.name << "\" state " + << thread.state; + if (!thread.waitChannel.empty() && thread.waitChannel != "0") { + text << " waiting in " << thread.waitChannel; + } + text << "\n"; + if (!thread.answered) { + text << " (did not answer the sample signal)\n"; + } + for (uintptr_t frame : thread.frames) { + text << " " << StackTrace::describe(frame) << "\n"; + } + text << "\n"; + } + return text.str(); + } + + void HangWatchdog::enterPhase(UiPhase phase) { + mPhaseStartNs.store(now(), std::memory_order_relaxed); + mPhaseInputEvents.store(mInputEvents.load(std::memory_order_relaxed), std::memory_order_relaxed); + mPhase.store(phase, std::memory_order_relaxed); + mBeat.fetch_add(1, std::memory_order_release); + } + + void HangWatchdog::noteInputEvents(uint64_t count) { mInputEvents.fetch_add(count, std::memory_order_relaxed); } + + int64_t HangWatchdog::now() const { return mSettings.clockNs(); } + + void HangWatchdog::addReportFileSource(ReportFileSource reportFileSource) { + std::lock_guard lock(mReportFileSourcesMutex); + mReportFileSources.push_back(std::move(reportFileSource)); + } + + std::filesystem::path HangWatchdog::defaultHangsDirectory() { + const char* home = std::getenv("HOME"); + const std::filesystem::path root = home && *home ? std::filesystem::path(home) : std::filesystem::current_path(); + return root / processName() / "hangs"; + } + + std::filesystem::path HangWatchdog::directoryFor(const std::filesystem::path& applicationHangsDirectory) { + if (const char* configured = std::getenv("MODULES_HANG_DIR"); configured && *configured) { + return configured; + } + return applicationHangsDirectory; + } + + std::string HangWatchdog::processName() { +#if defined(__linux__) + std::ifstream comm("/proc/self/comm"); + std::string name; + if (std::getline(comm, name) && !name.empty()) { + return name; + } +#endif + return "app"; + } + + void HangWatchdog::allowDebuggerAttach() { +#if defined(__linux__) && defined(PR_SET_PTRACER) + prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY, 0, 0, 0); +#endif + } + + const char* HangWatchdog::phaseName(UiPhase phase) { + switch (phase) { + case UiPhase::Idle: return "idle"; + case UiPhase::Input: return "processing input"; + case UiPhase::Draw: return "drawing"; + case UiPhase::Present: return "presenting"; + } + return "unknown"; + } + + void HangWatchdog::watch() { + Stall stall; + int64_t previous = now(); + std::unique_lock lock(mStopMutex); + while (!mStopping) { + mStopped.wait_for(lock, std::chrono::duration(kCheckIntervalMs), [this]() { + return mStopping; + }); + if (mStopping) { + return; + } + lock.unlock(); + const int64_t current = now(); + discountProcessPause(current, previous); + inspect(stall, current); + previous = current; + mInspections.fetch_add(1); + lock.lock(); + } + } + + void HangWatchdog::discountProcessPause(int64_t now, int64_t previous) { + const double sinceLastCheckMs = (double) (now - previous) / 1.0e6; + const double pausedMs = sinceLastCheckMs - kCheckIntervalMs; + if (pausedMs < mSettings.processPauseMs) { + return; + } + + const uint64_t beat = mBeat.load(std::memory_order_acquire); + if (beat != mPausedBeat) { + mPausedBeat = beat; + mPausedNs = 0; + } + mPausedNs += (int64_t) (pausedMs * 1.0e6); + } + + double HangWatchdog::stalledMs(int64_t now) const { + const bool pausedThisPhase = mPausedBeat == mBeat.load(std::memory_order_acquire); + const int64_t paused = pausedThisPhase ? mPausedNs : 0; + return (double) (now - mPhaseStartNs.load(std::memory_order_relaxed) - paused) / 1.0e6; + } + + uint64_t HangWatchdog::inputEventsInPhase() const { + return mInputEvents.load(std::memory_order_relaxed) - mPhaseInputEvents.load(std::memory_order_relaxed); + } + + void HangWatchdog::inspect(Stall& stall, int64_t now) { + const uint64_t beat = mBeat.load(std::memory_order_acquire); + const UiPhase phase = mPhase.load(std::memory_order_relaxed); + const double stalled = stalledMs(now); + + if (!stall.report.empty() && stall.beat != beat) { + finishRecovered(stall, stalled); + stall = {}; + } + if (phase == UiPhase::Idle || stalled < mSettings.thresholdMs) { + return; + } + + if (stall.report.empty()) { + stall.beat = beat; + stall.phase = phase; + beginReport(stall, stalled); + return; + } + if (stall.followUps < mSettings.followUpMs.size() && stalled >= mSettings.followUpMs[stall.followUps]) { + appendFollowUp(stall, stalled); + } + } + + std::string HangWatchdog::uiSnapshot(double stalled, UiPhase phase) const { + std::ostringstream text; + text << "ui thread " << phaseName(phase) << " for " << (int64_t) stalled << " ms\n"; + text << "input events handled during this phase " << inputEventsInPhase() << "\n"; + text << "scope trail (outermost first):\n"; + const std::vector names = mTrail.snapshot(); + if (names.empty()) { + text << " (no profiled scope open)\n"; + } + for (size_t index = 0; index < names.size(); index++) { + text << " " << std::string(index * 2, ' ') << names[index] << "\n"; + } + return text.str(); + } + + std::filesystem::path HangWatchdog::newReportPath() const { + const std::string base = mSettings.application + "-" + timestamp("%Y-%m-%d_%H-%M-%S") + "-build" + mSettings.build; + std::filesystem::path path = mSettings.directory / base; + for (int suffix = 2; std::filesystem::exists(path); suffix++) { + path = mSettings.directory / (base + "-" + std::to_string(suffix)); + } + return path; + } + + std::string HangWatchdog::reportHeader() const { + std::ostringstream text; + text << "application " << mSettings.application << "\n"; + text << "build " << mSettings.build << "\n"; + text << "detected " << timestamp("%Y-%m-%d %H:%M:%S") << "\n"; + text << "threshold " << (int64_t) mSettings.thresholdMs << " ms\n"; + text << "debugger attached " << (StackTrace::isDebuggerAttached() ? "yes" : "no") << "\n\n"; + return text.str(); + } + + void HangWatchdog::beginReport(Stall& stall, double stalled) { + std::error_code error; + std::filesystem::create_directories(mSettings.directory, error); + stall.report = newReportPath(); + std::filesystem::create_directories(stall.report, error); + if (error) { + return; + } + + writeFile( + stall.report / "summary.txt", reportHeader() + uiSnapshot(stalled, stall.phase), std::ios::out | std::ios::trunc + ); + writeSourcedFiles(stall.report); + mReportsWritten.fetch_add(1); + + const Investigation investigation = investigate(); + writeFile( + stall.report / "summary.txt", "\nverdict: " + investigation.verdict + "\n", std::ios::out | std::ios::app + ); + if (!investigation.threads.empty()) { + writeFile(stall.report / "threads.txt", investigation.threads, std::ios::out | std::ios::trunc); + } + pruneOldReports(); + } + + void HangWatchdog::writeSourcedFiles(const std::filesystem::path& report) { + std::vector reportFileSources; + { + std::lock_guard lock(mReportFileSourcesMutex); + reportFileSources = mReportFileSources; + } + for (const ReportFileSource& reportFileSource : reportFileSources) { + for (const HangReportFile& file : reportFileSource()) { + writeFile(report / file.fileName, file.text, std::ios::out | std::ios::trunc); + } + } + } + + void HangWatchdog::appendFollowUp(Stall& stall, double stalled) { + stall.followUps++; + const std::string header = "\nstill stuck at " + timestamp("%H:%M:%S") + "\n"; + writeFile(stall.report / "summary.txt", header + uiSnapshot(stalled, stall.phase), std::ios::out | std::ios::app); + + const Investigation investigation = investigate(); + writeFile(stall.report / "summary.txt", "verdict: " + investigation.verdict + "\n", std::ios::out | std::ios::app); + if (!investigation.threads.empty()) { + writeFile( + stall.report / "threads.txt", + "\n===== follow-up at " + timestamp("%H:%M:%S") + " =====\n" + investigation.threads, + std::ios::out | std::ios::app + ); + } + } + + void HangWatchdog::finishRecovered(Stall& stall, double stalled) { + (void) stalled; + writeFile( + stall.report / "summary.txt", "\nrecovered at " + timestamp("%H:%M:%S") + "\n", std::ios::out | std::ios::app + ); + } + + HangWatchdog::Investigation HangWatchdog::investigate() const { + if (StackTrace::isDebuggerAttached()) { + return { "not sampled: a debugger is attached, its thread view shows where the ui thread is", "" }; + } + if (!StackTrace::canSampleThreads()) { + return { "not sampled: thread stacks are not available on this platform", "" }; + } + + std::vector> uiSamples; + std::string uiState; + std::string uiWait; + std::string threads = sampleStacks(uiSamples, uiState, uiWait); + std::string verdict = stackVerdict(uiSamples, uiState, uiWait); + if (inputEventsInPhase() > 0) { + verdict += + "; " + std::to_string(inputEventsInPhase()) + " input events were handled without leaving the input phase"; + } + return { verdict, threads }; + } + + std::string HangWatchdog::sampleStacks( + std::vector>& uiSamples, std::string& uiState, std::string& uiWait + ) const { + std::ostringstream text; + for (int sample = 0; sample < std::max(1, mSettings.stackSamples); sample++) { + if (sample > 0) { + std::this_thread::sleep_for(std::chrono::duration(mSettings.stackSampleIntervalMs)); + } + const std::vector threads = StackTrace::sampleAllThreads(); + for (const ThreadSample& thread : threads) { + if (thread.tid == mUiTid) { + uiSamples.push_back(thread.frames); + uiState = thread.state; + uiWait = thread.waitChannel; + } + } + text << "===== sample " << sample + 1 << " =====\n" << formatThreads(threads, mUiTid); + } + return text.str(); + } + + std::string HangWatchdog::stackVerdict( + const std::vector>& uiSamples, const std::string& state, const std::string& waitChannel + ) { + if (uiSamples.empty()) { + return "ui thread not found"; + } + const bool sameStack = + std::all_of(uiSamples.begin(), uiSamples.end(), [&](const auto& frames) { return frames == uiSamples.front(); }); + const bool sleeping = !state.empty() && (state[0] == 'S' || state[0] == 'D'); + if (sameStack && sleeping) { + std::string text = "blocked: the ui thread sleeps at the same place in every sample"; + if (!waitChannel.empty() && waitChannel != "0") { + text += " (kernel wait " + waitChannel + ")"; + } + return text; + } + if (!sleeping) { + return "busy: the ui thread is running, check the changing frames in threads.txt"; + } + return "waiting repeatedly: the ui thread sleeps but at different places, likely polling or lock contention"; + } + + std::vector HangWatchdog::reports() const { + std::vector found; + std::error_code error; + for (const auto& entry : std::filesystem::directory_iterator(mSettings.directory, error)) { + if (entry.is_directory()) { + found.push_back(entry.path()); + } + } + std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { + return std::filesystem::last_write_time(a) < std::filesystem::last_write_time(b); + }); + return found; + } + + void HangWatchdog::pruneOldReports() const { + const std::vector found = reports(); + if (found.size() <= mSettings.keptReports) { + return; + } + std::error_code error; + for (size_t index = 0; index + mSettings.keptReports < found.size(); index++) { + std::filesystem::remove_all(found[index], error); + } + } +} diff --git a/GraphicApplication/src/diagnostics/private/Profiler.cpp b/GraphicApplication/src/diagnostics/private/Profiler.cpp new file mode 100644 index 00000000..973b320c --- /dev/null +++ b/GraphicApplication/src/diagnostics/private/Profiler.cpp @@ -0,0 +1,219 @@ +#include "Profiler.hpp" + +#include +#include +#include +#include + +namespace tp { + + namespace { + double clockMs(clockid_t clock) { + timespec now{}; + clock_gettime(clock, &now); + return (double) now.tv_sec * 1000.0 + (double) now.tv_nsec / 1.0e6; + } + + int64_t monotonicNs() { + timespec now{}; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t) now.tv_sec * 1000000000ll + now.tv_nsec; + } + + double percentile(const std::vector& sorted, double fraction) { + if (sorted.empty()) { + return 0; + } + const double position = fraction * (double) (sorted.size() - 1); + const size_t lower = (size_t) std::floor(position); + const size_t upper = std::min(lower + 1, sorted.size() - 1); + const double blend = position - (double) lower; + return sorted[lower] + (sorted[upper] - sorted[lower]) * blend; + } + } + + ProfileStatistics summarize(std::vector values) { + ProfileStatistics statistics; + statistics.samples = values.size(); + if (values.empty()) { + return statistics; + } + + std::sort(values.begin(), values.end()); + for (double value : values) { + statistics.total += value; + } + statistics.mean = statistics.total / (double) values.size(); + statistics.minimum = values.front(); + statistics.maximum = values.back(); + statistics.median = percentile(values, 0.5); + statistics.p95 = percentile(values, 0.95); + statistics.p99 = percentile(values, 0.99); + return statistics; + } + + void Profiler::setEnabled(bool enabled) { + mEnabled = enabled; + if (!enabled) { + mInFrame = false; + } + } + + ProfileId Profiler::intern(std::deque& names, const char* name) { + const ptrdiff_t existing = indexOf(names, name); + if (existing >= 0) { + return (ProfileId) existing; + } + if (names.size() >= kMaxNames) { + return (ProfileId) (kMaxNames - 1); + } + names.emplace_back(name); + return (ProfileId) (names.size() - 1); + } + + ptrdiff_t Profiler::indexOf(const std::deque& names, const std::string& name) { + const auto found = std::find(names.begin(), names.end(), name); + return found == names.end() ? -1 : found - names.begin(); + } + + ProfileId Profiler::section(const char* name) { return intern(mSectionNames, name); } + + ProfileId Profiler::counter(const char* name) { return intern(mCounterNames, name); } + + const char* Profiler::sectionName(ProfileId section) const { + return section < mSectionNames.size() ? mSectionNames[section].c_str() : "?"; + } + + void Profiler::beginFrame() { + if (!mEnabled) { + return; + } + mSectionMs.fill(0); + mSectionCalls.fill(0); + mCounters.fill(0); + mFrameWallStart = clockMs(CLOCK_MONOTONIC); + mFrameCpuStart = threadCpuMs(); + mInFrame = true; + } + + void Profiler::endFrame() { + if (!mEnabled || !mInFrame) { + return; + } + mInFrame = false; + + FrameProfile frame; + frame.index = mNextFrame++; + frame.wallMs = clockMs(CLOCK_MONOTONIC) - mFrameWallStart; + frame.cpuMs = threadCpuMs() - mFrameCpuStart; + frame.sectionMs.assign(mSectionMs.begin(), mSectionMs.begin() + (ptrdiff_t) mSectionNames.size()); + frame.sectionCalls.assign(mSectionCalls.begin(), mSectionCalls.begin() + (ptrdiff_t) mSectionNames.size()); + frame.counters.assign(mCounters.begin(), mCounters.begin() + (ptrdiff_t) mCounterNames.size()); + mFrames.push_back(std::move(frame)); + } + + void Profiler::addSectionTime(ProfileId section, double milliseconds) { + if (!mInFrame || section >= kMaxNames) { + return; + } + mSectionMs[section] += milliseconds; + mSectionCalls[section]++; + } + + void Profiler::addCounter(ProfileId counter, double amount) { + if (!mInFrame || counter >= kMaxNames) { + return; + } + mCounters[counter] += amount; + } + + void Profiler::clear() { + mFrames.clear(); + mNextFrame = 0; + mInFrame = false; + } + + std::vector Profiler::sectionSeries(const std::string& name) const { + std::vector series; + const ptrdiff_t index = indexOf(mSectionNames, name); + if (index < 0) { + return series; + } + for (const FrameProfile& frame : mFrames) { + series.push_back((size_t) index < frame.sectionMs.size() ? frame.sectionMs[(size_t) index] : 0.0); + } + return series; + } + + std::vector Profiler::counterSeries(const std::string& name) const { + std::vector series; + const ptrdiff_t index = indexOf(mCounterNames, name); + if (index < 0) { + return series; + } + for (const FrameProfile& frame : mFrames) { + series.push_back((size_t) index < frame.counters.size() ? frame.counters[(size_t) index] : 0.0); + } + return series; + } + + double Profiler::counterTotal(const std::string& name) const { + double total = 0; + for (double value : counterSeries(name)) { + total += value; + } + return total; + } + + double Profiler::sectionTotal(const std::string& name) const { + double total = 0; + for (double value : sectionSeries(name)) { + total += value; + } + return total; + } + + uint64_t Profiler::sectionCallTotal(const std::string& name) const { + uint64_t total = 0; + const ptrdiff_t index = indexOf(mSectionNames, name); + if (index < 0) { + return 0; + } + for (const FrameProfile& frame : mFrames) { + if ((size_t) index < frame.sectionCalls.size()) { + total += frame.sectionCalls[(size_t) index]; + } + } + return total; + } + + double Profiler::threadCpuMs() { return clockMs(CLOCK_THREAD_CPUTIME_ID); } + + double Profiler::processCpuMs() { return clockMs(CLOCK_PROCESS_CPUTIME_ID); } + + double Profiler::peakResidentMegabytes() { + rusage usage{}; + getrusage(RUSAGE_SELF, &usage); + return (double) usage.ru_maxrss / 1024.0; + } + + ProfileScope::ProfileScope(Profiler& profiler, ProfileId section) : + mProfiler(profiler), + mSection(section) { + mProfiler.trail().push(mProfiler.sectionName(section)); + if (mProfiler.isInFrame()) { + mStartNs = monotonicNs(); + } + } + + ProfileScope::ProfileScope(Profiler& profiler, const char* sectionName) : + ProfileScope(profiler, profiler.section(sectionName)) {} + + ProfileScope::~ProfileScope() { + mProfiler.trail().pop(); + if (mStartNs < 0) { + return; + } + mProfiler.addSectionTime(mSection, (double) (monotonicNs() - mStartNs) / 1.0e6); + } +} diff --git a/GraphicApplication/src/diagnostics/private/UiTrail.cpp b/GraphicApplication/src/diagnostics/private/UiTrail.cpp new file mode 100644 index 00000000..609915bb --- /dev/null +++ b/GraphicApplication/src/diagnostics/private/UiTrail.cpp @@ -0,0 +1,59 @@ +#include "UiTrail.hpp" + +#include + +namespace tp { + + void UiTrail::push(const char* name) { + if (!isTracked()) { + return; + } + const int depth = mDepth.load(std::memory_order_relaxed); + if (depth < kDepth) { + mNames[depth].store(name, std::memory_order_relaxed); + } else { + mOverflow.fetch_add(1, std::memory_order_relaxed); + } + mDepth.store(depth + 1, std::memory_order_release); + } + + void UiTrail::pop() { + if (!isTracked()) { + return; + } + const int depth = mDepth.load(std::memory_order_relaxed); + if (depth <= 0) { + return; + } + if (depth > kDepth) { + mOverflow.fetch_sub(1, std::memory_order_relaxed); + } + mDepth.store(depth - 1, std::memory_order_release); + } + + bool UiTrail::isTracked() const { + return mTrackedThread.load(std::memory_order_relaxed) == std::this_thread::get_id(); + } + + void UiTrail::trackCallingThread() { + mDepth.store(0); + mOverflow.store(0); + mTrackedThread.store(std::this_thread::get_id()); + } + + void UiTrail::stopTracking() { mTrackedThread.store(std::thread::id{}); } + + std::vector UiTrail::snapshot() const { + const int depth = std::min(mDepth.load(std::memory_order_acquire), kDepth); + std::vector names; + for (int index = 0; index < depth; index++) { + const char* name = mNames[index].load(std::memory_order_relaxed); + names.emplace_back(name ? name : "?"); + } + const int overflow = mOverflow.load(std::memory_order_relaxed); + if (overflow > 0) { + names.push_back("... " + std::to_string(overflow) + " deeper"); + } + return names; + } +} diff --git a/GraphicApplication/src/diagnostics/public/HangWatchdog.hpp b/GraphicApplication/src/diagnostics/public/HangWatchdog.hpp new file mode 100644 index 00000000..766be64a --- /dev/null +++ b/GraphicApplication/src/diagnostics/public/HangWatchdog.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include "StackTrace.hpp" +#include "UiTrail.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tp { + + enum class UiPhase : uint8_t { Idle, Input, Draw, Present }; + + struct HangSettings { + double thresholdMs = 2000; + std::vector followUpMs = { 10000, 30000 }; + int stackSamples = 5; + double stackSampleIntervalMs = 200; + double processPauseMs = 1000; + size_t keptReports = 20; + std::string application = "app"; + std::string build = "dev"; + std::filesystem::path directory; + std::function clockNs; + }; + + struct HangReportFile { + std::string fileName; + std::string text; + }; + + class HangWatchdog { + public: + using ReportFileSource = std::function()>; + + HangWatchdog(HangSettings settings, UiTrail& trail); + ~HangWatchdog(); + + HangWatchdog(const HangWatchdog&) = delete; + HangWatchdog& operator=(const HangWatchdog&) = delete; + + void enterPhase(UiPhase phase); + void noteInputEvents(uint64_t count = 1); + void addReportFileSource(ReportFileSource reportFileSource); + + [[nodiscard]] const std::filesystem::path& directory() const { return mSettings.directory; } + [[nodiscard]] std::vector reports() const; + [[nodiscard]] size_t reportsWritten() const { return mReportsWritten.load(); } + [[nodiscard]] uint64_t inspections() const { return mInspections.load(); } + + [[nodiscard]] static std::filesystem::path defaultHangsDirectory(); + [[nodiscard]] static std::filesystem::path directoryFor(const std::filesystem::path& applicationHangsDirectory); + [[nodiscard]] static std::string processName(); + static void allowDebuggerAttach(); + [[nodiscard]] static const char* phaseName(UiPhase phase); + + private: + struct Stall { + uint64_t beat = 0; + UiPhase phase = UiPhase::Idle; + std::filesystem::path report; + size_t followUps = 0; + }; + + struct Investigation { + std::string verdict; + std::string threads; + }; + + void watch(); + void inspect(Stall& stall, int64_t now); + void discountProcessPause(int64_t now, int64_t previous); + [[nodiscard]] double stalledMs(int64_t now) const; + [[nodiscard]] uint64_t inputEventsInPhase() const; + + void beginReport(Stall& stall, double stalled); + void appendFollowUp(Stall& stall, double stalled); + void finishRecovered(Stall& stall, double stalled); + [[nodiscard]] std::string reportHeader() const; + [[nodiscard]] std::string uiSnapshot(double stalled, UiPhase phase) const; + void writeSourcedFiles(const std::filesystem::path& report); + [[nodiscard]] Investigation investigate() const; + [[nodiscard]] std::string + sampleStacks(std::vector>& uiSamples, std::string& uiState, std::string& uiWait) const; + [[nodiscard]] static std::string formatThreads(const std::vector& threads, int uiTid); + [[nodiscard]] static std::string stackVerdict( + const std::vector>& uiSamples, const std::string& state, const std::string& waitChannel + ); + + void pruneOldReports() const; + [[nodiscard]] std::filesystem::path newReportPath() const; + [[nodiscard]] int64_t now() const; + + HangSettings mSettings; + UiTrail& mTrail; + std::atomic mBeat{ 0 }; + std::atomic mPhaseStartNs{ 0 }; + std::atomic mPhase{ UiPhase::Idle }; + std::atomic mInputEvents{ 0 }; + std::atomic mPhaseInputEvents{ 0 }; + std::atomic mReportsWritten{ 0 }; + std::atomic mInspections{ 0 }; + int mUiTid = 0; + + uint64_t mPausedBeat = 0; + int64_t mPausedNs = 0; + + std::mutex mReportFileSourcesMutex; + std::vector mReportFileSources; + + std::mutex mStopMutex; + std::condition_variable mStopped; + bool mStopping = false; + std::thread mThread; + }; +} diff --git a/GraphicApplication/src/diagnostics/public/Profiler.hpp b/GraphicApplication/src/diagnostics/public/Profiler.hpp new file mode 100644 index 00000000..0a0bd8a4 --- /dev/null +++ b/GraphicApplication/src/diagnostics/public/Profiler.hpp @@ -0,0 +1,116 @@ +#pragma once + +#include "UiTrail.hpp" + +#include +#include +#include +#include +#include + +namespace tp { + + using ProfileId = uint32_t; + + struct FrameProfile { + uint64_t index = 0; + double wallMs = 0; + double cpuMs = 0; + std::vector sectionMs; + std::vector sectionCalls; + std::vector counters; + }; + + struct ProfileStatistics { + size_t samples = 0; + double total = 0; + double mean = 0; + double minimum = 0; + double median = 0; + double p95 = 0; + double p99 = 0; + double maximum = 0; + }; + + [[nodiscard]] ProfileStatistics summarize(std::vector values); + + // A frame holds two generic channels: sections measure milliseconds, counters + // measure amounts. Nothing here knows which device or subsystem produced a + // number - a GPU timer reports into the section channel like any other sampler. + class Profiler { + public: + static constexpr size_t kMaxNames = 128; + + void setEnabled(bool enabled); + [[nodiscard]] bool isEnabled() const { return mEnabled; } + + [[nodiscard]] ProfileId section(const char* name); + [[nodiscard]] ProfileId counter(const char* name); + + void beginFrame(); + void endFrame(); + [[nodiscard]] bool isInFrame() const { return mInFrame; } + + // A duration measured outside the profiler (a GPU timer, a blocking wait, ...). + // ProfileScope does the same for a scoped block. Both count one sample. + void addSectionTime(ProfileId section, double milliseconds); + void addCounter(ProfileId counter, double amount); + + void clear(); + + [[nodiscard]] const std::vector& frames() const { return mFrames; } + [[nodiscard]] const std::deque& sectionNames() const { return mSectionNames; } + [[nodiscard]] const std::deque& counterNames() const { return mCounterNames; } + [[nodiscard]] const char* sectionName(ProfileId section) const; + + [[nodiscard]] std::vector sectionSeries(const std::string& name) const; + [[nodiscard]] std::vector counterSeries(const std::string& name) const; + [[nodiscard]] double counterTotal(const std::string& name) const; + [[nodiscard]] double sectionTotal(const std::string& name) const; + [[nodiscard]] uint64_t sectionCallTotal(const std::string& name) const; + + // Open profile scopes of the frame loop, readable from a watchdog thread. + [[nodiscard]] UiTrail& trail() { return mTrail; } + [[nodiscard]] const UiTrail& trail() const { return mTrail; } + + [[nodiscard]] static double threadCpuMs(); + [[nodiscard]] static double processCpuMs(); + [[nodiscard]] static double peakResidentMegabytes(); + + private: + static ProfileId intern(std::deque& names, const char* name); + [[nodiscard]] static ptrdiff_t indexOf(const std::deque& names, const std::string& name); + + bool mEnabled = false; + bool mInFrame = false; + uint64_t mNextFrame = 0; + double mFrameWallStart = 0; + double mFrameCpuStart = 0; + + std::deque mSectionNames; + std::deque mCounterNames; + + std::array mSectionMs{}; + std::array mSectionCalls{}; + std::array mCounters{}; + + std::vector mFrames; + UiTrail mTrail; + }; + + // Times a block into one section and records it on the trail while it is open. + class ProfileScope { + public: + ProfileScope(Profiler& profiler, ProfileId section); + ProfileScope(Profiler& profiler, const char* sectionName); + ~ProfileScope(); + + ProfileScope(const ProfileScope&) = delete; + ProfileScope& operator=(const ProfileScope&) = delete; + + private: + Profiler& mProfiler; + ProfileId mSection; + int64_t mStartNs = -1; + }; +} diff --git a/GraphicApplication/src/diagnostics/public/UiTrail.hpp b/GraphicApplication/src/diagnostics/public/UiTrail.hpp new file mode 100644 index 00000000..81ae6b59 --- /dev/null +++ b/GraphicApplication/src/diagnostics/public/UiTrail.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include + +namespace tp { + + // The stack of named scopes open on the tracked thread; another thread may take a snapshot at any time. + class UiTrail { + public: + static constexpr int kDepth = 32; + + void push(const char* name); + void pop(); + + void trackCallingThread(); + void stopTracking(); + + [[nodiscard]] std::vector snapshot() const; + + private: + [[nodiscard]] bool isTracked() const; + + std::atomic mNames[kDepth] = {}; + std::atomic mDepth{ 0 }; + std::atomic mOverflow{ 0 }; + std::atomic mTrackedThread{}; + }; +} diff --git a/GraphicApplication/src/gpu/private/GpuDevice.cpp b/GraphicApplication/src/gpu/private/GpuDevice.cpp new file mode 100644 index 00000000..f70b5bf9 --- /dev/null +++ b/GraphicApplication/src/gpu/private/GpuDevice.cpp @@ -0,0 +1,226 @@ +#include "GpuDevice.hpp" + +#include "GpuError.hpp" + +#include +#include +#include +#include + +namespace tp { + + namespace { + constexpr std::array kDeviceExtensions = { VK_KHR_SWAPCHAIN_EXTENSION_NAME }; + constexpr const char* kValidationLayer = "VK_LAYER_KHRONOS_validation"; + + bool validationRequested() { + const char* value = std::getenv("MODULES_VULKAN_VALIDATION"); + return value && std::strcmp(value, "1") == 0; + } + + bool hasValidationLayer() { + uint32_t count = 0; + vkEnumerateInstanceLayerProperties(&count, nullptr); + std::vector layers(count); + vkEnumerateInstanceLayerProperties(&count, layers.data()); + for (const VkLayerProperties& layer : layers) { + if (std::strcmp(layer.layerName, kValidationLayer) == 0) { + return true; + } + } + return false; + } + + bool supportsDeviceExtensions(VkPhysicalDevice device) { + uint32_t count = 0; + vkEnumerateDeviceExtensionProperties(device, nullptr, &count, nullptr); + std::vector available(count); + vkEnumerateDeviceExtensionProperties(device, nullptr, &count, available.data()); + for (const char* required : kDeviceExtensions) { + const bool found = + std::any_of(available.begin(), available.end(), [required](const VkExtensionProperties& extension) { + return std::strcmp(required, extension.extensionName) == 0; + }); + if (!found) { + return false; + } + } + return true; + } + + uint32_t graphicsFamilyOf(VkPhysicalDevice device) { + uint32_t count = 0; + vkGetPhysicalDeviceQueueFamilyProperties(device, &count, nullptr); + std::vector families(count); + vkGetPhysicalDeviceQueueFamilyProperties(device, &count, families.data()); + for (uint32_t index = 0; index < count; index++) { + if (families[index].queueFlags & VK_QUEUE_GRAPHICS_BIT) { + return index; + } + } + return UINT32_MAX; + } + } + + GpuDevice::GpuDevice(const std::vector& surfaceExtensions) { + createInstance(surfaceExtensions); + selectPhysicalDevice(); + createLogicalDevice(); + createCommandPool(); + } + + GpuDevice::~GpuDevice() { + waitIdle(); + if (mCommandPool != VK_NULL_HANDLE) { + vkDestroyCommandPool(mDevice, mCommandPool, nullptr); + } + if (mDevice != VK_NULL_HANDLE) { + vkDestroyDevice(mDevice, nullptr); + } + if (mInstance != VK_NULL_HANDLE) { + vkDestroyInstance(mInstance, nullptr); + } + } + + void GpuDevice::createInstance(const std::vector& surfaceExtensions) { + std::vector layers; + if (validationRequested() && hasValidationLayer()) { + layers.push_back(kValidationLayer); + } + + const VkApplicationInfo appInfo{ + .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, + .pApplicationName = "Modules", + .apiVersion = VK_API_VERSION_1_2, + }; + + const VkInstanceCreateInfo createInfo{ + .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, + .pApplicationInfo = &appInfo, + .enabledLayerCount = (uint32_t) layers.size(), + .ppEnabledLayerNames = layers.data(), + .enabledExtensionCount = (uint32_t) surfaceExtensions.size(), + .ppEnabledExtensionNames = surfaceExtensions.data(), + }; + requireSuccess(vkCreateInstance(&createInfo, nullptr, &mInstance), "vkCreateInstance"); + } + + void GpuDevice::selectPhysicalDevice() { + uint32_t count = 0; + vkEnumeratePhysicalDevices(mInstance, &count, nullptr); + std::vector devices(count); + vkEnumeratePhysicalDevices(mInstance, &count, devices.data()); + + for (VkPhysicalDevice device : devices) { + const uint32_t graphics = graphicsFamilyOf(device); + if (graphics == UINT32_MAX || !supportsDeviceExtensions(device)) { + continue; + } + mPhysicalDevice = device; + mGraphicsQueueFamily = graphics; + vkGetPhysicalDeviceProperties(device, &mProperties); + return; + } + throw GpuError("selecting a graphics device with swapchain support", VK_ERROR_INITIALIZATION_FAILED); + } + + void GpuDevice::createLogicalDevice() { + const float priority = 1.0f; + const VkDeviceQueueCreateInfo queueInfo{ + .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, + .queueFamilyIndex = mGraphicsQueueFamily, + .queueCount = 1, + .pQueuePriorities = &priority, + }; + + VkPhysicalDeviceFeatures supported{}; + vkGetPhysicalDeviceFeatures(mPhysicalDevice, &supported); + VkPhysicalDeviceFeatures features{}; + features.geometryShader = supported.geometryShader; + + const VkDeviceCreateInfo createInfo{ + .sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, + .queueCreateInfoCount = 1, + .pQueueCreateInfos = &queueInfo, + .enabledExtensionCount = (uint32_t) kDeviceExtensions.size(), + .ppEnabledExtensionNames = kDeviceExtensions.data(), + .pEnabledFeatures = &features, + }; + requireSuccess(vkCreateDevice(mPhysicalDevice, &createInfo, nullptr, &mDevice), "vkCreateDevice"); + vkGetDeviceQueue(mDevice, mGraphicsQueueFamily, 0, &mGraphicsQueue); + } + + void GpuDevice::createCommandPool() { + const VkCommandPoolCreateInfo poolInfo{ + .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, + .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, + .queueFamilyIndex = mGraphicsQueueFamily, + }; + requireSuccess(vkCreateCommandPool(mDevice, &poolInfo, nullptr, &mCommandPool), "vkCreateCommandPool"); + } + + bool GpuDevice::canPresent(VkSurfaceKHR surface) const { + VkBool32 supported = VK_FALSE; + vkGetPhysicalDeviceSurfaceSupportKHR(mPhysicalDevice, mGraphicsQueueFamily, surface, &supported); + return supported == VK_TRUE; + } + + uint32_t GpuDevice::findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags properties) const { + VkPhysicalDeviceMemoryProperties memory{}; + vkGetPhysicalDeviceMemoryProperties(mPhysicalDevice, &memory); + for (uint32_t index = 0; index < memory.memoryTypeCount; index++) { + const bool allowed = typeFilter & (1u << index); + if (allowed && (memory.memoryTypes[index].propertyFlags & properties) == properties) { + return index; + } + } + throw GpuError("finding a memory type", VK_ERROR_FEATURE_NOT_PRESENT); + } + + VkCommandBuffer GpuDevice::beginOneShotCommands() const { + const VkCommandBufferAllocateInfo allocateInfo{ + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = mCommandPool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1, + }; + VkCommandBuffer commandBuffer = VK_NULL_HANDLE; + requireSuccess(vkAllocateCommandBuffers(mDevice, &allocateInfo, &commandBuffer), "vkAllocateCommandBuffers"); + + const VkCommandBufferBeginInfo beginInfo{ + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + }; + requireSuccess(vkBeginCommandBuffer(commandBuffer, &beginInfo), "vkBeginCommandBuffer"); + return commandBuffer; + } + + // Waits on a dedicated fence rather than the whole queue, so in-flight window frames keep running. + void GpuDevice::endOneShotCommands(VkCommandBuffer commandBuffer) const { + requireSuccess(vkEndCommandBuffer(commandBuffer), "vkEndCommandBuffer"); + + const VkFenceCreateInfo fenceInfo{ .sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO }; + VkFence fence = VK_NULL_HANDLE; + requireSuccess(vkCreateFence(mDevice, &fenceInfo, nullptr, &fence), "vkCreateFence"); + + const VkSubmitInfo submitInfo{ + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .commandBufferCount = 1, + .pCommandBuffers = &commandBuffer, + }; + const VkResult submitted = vkQueueSubmit(mGraphicsQueue, 1, &submitInfo, fence); + if (submitted == VK_SUCCESS) { + vkWaitForFences(mDevice, 1, &fence, VK_TRUE, UINT64_MAX); + } + + vkDestroyFence(mDevice, fence, nullptr); + vkFreeCommandBuffers(mDevice, mCommandPool, 1, &commandBuffer); + requireSuccess(submitted, "vkQueueSubmit"); + } + + void GpuDevice::waitIdle() const { + if (mDevice != VK_NULL_HANDLE) { + vkDeviceWaitIdle(mDevice); + } + } +} diff --git a/GraphicApplication/src/gpu/private/GpuTimer.cpp b/GraphicApplication/src/gpu/private/GpuTimer.cpp new file mode 100644 index 00000000..b0428099 --- /dev/null +++ b/GraphicApplication/src/gpu/private/GpuTimer.cpp @@ -0,0 +1,96 @@ +#include "GpuTimer.hpp" + +#include + +namespace tp { + + GpuTimer::GpuTimer( + VkDevice device, VkPhysicalDevice physicalDevice, uint32_t queueFamily, Profiler& profiler, const char* name + ) : + mDevice(device), + mProfiler(profiler), + mSection(profiler.section(name)), + mPending(kSlots, false) { + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(physicalDevice, &properties); + + uint32_t familyCount = 0; + vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &familyCount, nullptr); + std::vector families(familyCount); + vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &familyCount, families.data()); + + const uint32_t validBits = queueFamily < familyCount ? families[queueFamily].timestampValidBits : 0; + if (validBits == 0 || properties.limits.timestampPeriod <= 0.0f) { + return; + } + + mTickNs = properties.limits.timestampPeriod; + mValidMask = validBits >= 64 ? ~0ull : ((1ull << validBits) - 1ull); + + VkQueryPoolCreateInfo info{ + .sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO, + .queryType = VK_QUERY_TYPE_TIMESTAMP, + .queryCount = kSlots * 2, + }; + if (vkCreateQueryPool(mDevice, &info, nullptr, &mPool) != VK_SUCCESS) { + mPool = VK_NULL_HANDLE; + } + } + + GpuTimer::~GpuTimer() { + if (mPool != VK_NULL_HANDLE) { + vkDestroyQueryPool(mDevice, mPool, nullptr); + } + } + + void GpuTimer::begin(VkCommandBuffer commandBuffer) { + mOpenSlot = UINT32_MAX; + if (!isSupported() || !mProfiler.isEnabled()) { + return; + } + + collect(); + const uint32_t slot = mNextSlot; + if (mPending[slot]) { + return; + } + + vkCmdResetQueryPool(commandBuffer, mPool, slot * 2, 2); + vkCmdWriteTimestamp(commandBuffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, mPool, slot * 2); + mOpenSlot = slot; + } + + void GpuTimer::end(VkCommandBuffer commandBuffer) { + if (mOpenSlot == UINT32_MAX) { + return; + } + vkCmdWriteTimestamp(commandBuffer, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, mPool, mOpenSlot * 2 + 1); + mPending[mOpenSlot] = true; + mNextSlot = (mOpenSlot + 1) % kSlots; + mOpenSlot = UINT32_MAX; + } + + void GpuTimer::collect() { + if (!isSupported()) { + return; + } + for (uint32_t offset = 0; offset < kSlots; offset++) { + const uint32_t slot = (mNextSlot + offset) % kSlots; + if (!mPending[slot]) { + continue; + } + + uint64_t stamps[2] = {}; + const VkResult result = vkGetQueryPoolResults( + mDevice, mPool, slot * 2, 2, sizeof(stamps), stamps, sizeof(uint64_t), VK_QUERY_RESULT_64_BIT + ); + if (result != VK_SUCCESS) { + continue; + } + + mPending[slot] = false; + const uint64_t ticks = (stamps[1] - stamps[0]) & mValidMask; + mProfiler.addSectionTime(mSection, (double) ticks * mTickNs / 1.0e6); + } + } +} diff --git a/GraphicApplication/src/gpu/private/GpuTimer.hpp b/GraphicApplication/src/gpu/private/GpuTimer.hpp new file mode 100644 index 00000000..bc9c5732 --- /dev/null +++ b/GraphicApplication/src/gpu/private/GpuTimer.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "Profiler.hpp" + +#include + +#include +#include + +namespace tp { + + // Measures GPU time of recorded command ranges with timestamp queries and reports it as a profiler section. + class GpuTimer { + public: + static constexpr uint32_t kSlots = 8; + + GpuTimer( + VkDevice device, VkPhysicalDevice physicalDevice, uint32_t queueFamily, Profiler& profiler, const char* name + ); + ~GpuTimer(); + + GpuTimer(const GpuTimer&) = delete; + GpuTimer& operator=(const GpuTimer&) = delete; + + void begin(VkCommandBuffer commandBuffer); + void end(VkCommandBuffer commandBuffer); + + private: + [[nodiscard]] bool isSupported() const { return mPool != VK_NULL_HANDLE; } + void collect(); + + VkDevice mDevice; + Profiler& mProfiler; + ProfileId mSection; + VkQueryPool mPool = VK_NULL_HANDLE; + double mTickNs = 1; + uint64_t mValidMask = ~0ull; + + std::vector mPending; + uint32_t mNextSlot = 0; + uint32_t mOpenSlot = UINT32_MAX; + }; +} diff --git a/GraphicApplication/src/gpu/private/RenderTargetRegistry.cpp b/GraphicApplication/src/gpu/private/RenderTargetRegistry.cpp new file mode 100644 index 00000000..2d8b56d2 --- /dev/null +++ b/GraphicApplication/src/gpu/private/RenderTargetRegistry.cpp @@ -0,0 +1,23 @@ +#include "RenderTargetRegistry.hpp" + +namespace tp { + + uint32_t RenderTargetRegistry::registerTarget(const RenderTargetEntry& entry) { + const uint32_t id = mNextId++; + mEntries[id] = entry; + return id; + } + + void RenderTargetRegistry::updateTarget(uint32_t id, const RenderTargetEntry& entry) { mEntries[id] = entry; } + + void RenderTargetRegistry::unregisterTarget(uint32_t id) { mEntries.erase(id); } + + bool RenderTargetRegistry::resolve(uint32_t id, RenderTargetEntry& out) const { + const auto found = mEntries.find(id); + if (found == mEntries.end()) { + return false; + } + out = found->second; + return true; + } +} diff --git a/GraphicApplication/src/gpu/private/WindowSurface.cpp b/GraphicApplication/src/gpu/private/WindowSurface.cpp new file mode 100644 index 00000000..dcf145c4 --- /dev/null +++ b/GraphicApplication/src/gpu/private/WindowSurface.cpp @@ -0,0 +1,571 @@ +#include "WindowSurface.hpp" + +#include "GpuError.hpp" +#include "GpuTimer.hpp" +#include "Profiler.hpp" + +#include +#include +#include + +namespace tp { + + namespace { + constexpr uint32_t kUndefinedExtent = std::numeric_limits::max(); + + VkExtent2D atLeastOnePixel(VkExtent2D extent) { + return { std::max(extent.width, 1u), std::max(extent.height, 1u) }; + } + + bool isEmpty(VkExtent2D extent) { return extent.width == 0 || extent.height == 0; } + + VkSurfaceCapabilitiesKHR capabilitiesOf(VkPhysicalDevice device, VkSurfaceKHR surface) { + VkSurfaceCapabilitiesKHR capabilities{}; + vkGetPhysicalDeviceSurfaceCapabilitiesKHR(device, surface, &capabilities); + return capabilities; + } + } + + WindowSurface::WindowSurface(GpuDevice& gpu, VulkanSurfaceSource& source, Profiler& profiler) : + mGpu(gpu), + mDevice(gpu.device()), + mSource(source), + mProfiler(profiler), + mAcquireSection(profiler.section("frame.acquire")), + mSubmitSection(profiler.section("frame.submit")) { + createSurface(); + selectCompositeAlpha(); + createRenderPass(); + createFrameSlots(); + mFrameTimer = + std::make_unique(mDevice, gpu.physicalDevice(), gpu.graphicsQueueFamily(), profiler, "gpu.window"); + + const VkExtent2D extent = mSource.framebufferExtent(); + createSwapchain(atLeastOnePixel(extent)); + mRequestedExtent = extent; + } + + WindowSurface::~WindowSurface() { + mGpu.waitIdle(); + mFrameTimer.reset(); + destroySwapchain(); + destroyFrameSlots(); + if (mRenderPass != VK_NULL_HANDLE) { + vkDestroyRenderPass(mDevice, mRenderPass, nullptr); + } + if (mSurface != VK_NULL_HANDLE) { + vkDestroySurfaceKHR(mGpu.instance(), mSurface, nullptr); + } + } + + void WindowSurface::createSurface() { + mSurface = mSource.createSurface(mGpu.instance()); + if (mSurface == VK_NULL_HANDLE) { + throw GpuError("creating the window surface", VK_ERROR_SURFACE_LOST_KHR); + } + if (!mGpu.canPresent(mSurface)) { + throw GpuError("presenting to the window surface from the graphics queue", VK_ERROR_INCOMPATIBLE_DISPLAY_KHR); + } + } + + void WindowSurface::selectCompositeAlpha() { + const VkCompositeAlphaFlagsKHR supported = capabilitiesOf(mGpu.physicalDevice(), mSurface).supportedCompositeAlpha; + + mCompositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; + if (!(supported & VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR)) { + for (VkCompositeAlphaFlagBitsKHR candidate : { VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR, + VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR, + VK_COMPOSITE_ALPHA_POST_MULTIPLIED_BIT_KHR }) { + if (supported & candidate) { + mCompositeAlpha = candidate; + break; + } + } + } + if (mSource.wantsTranslucency() && (supported & VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR)) { + mCompositeAlpha = VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR; + mTranslucent = true; + } + + mSource.onTranslucencyResolved(mTranslucent); + } + + void WindowSurface::createRenderPass() { + uint32_t formatCount = 0; + vkGetPhysicalDeviceSurfaceFormatsKHR(mGpu.physicalDevice(), mSurface, &formatCount, nullptr); + std::vector formats(formatCount); + vkGetPhysicalDeviceSurfaceFormatsKHR(mGpu.physicalDevice(), mSurface, &formatCount, formats.data()); + if (formats.empty()) { + throw GpuError("querying surface formats", VK_ERROR_FORMAT_NOT_SUPPORTED); + } + + mSwapchainFormat = formats.front().format; + for (const VkSurfaceFormatKHR& format : formats) { + if (format.format == VK_FORMAT_B8G8R8A8_UNORM && format.colorSpace == VK_COLORSPACE_SRGB_NONLINEAR_KHR) { + mSwapchainFormat = format.format; + break; + } + } + mDepthStencilFormat = selectDepthStencilFormat(); + + const std::array attachments = { + VkAttachmentDescription{ + .format = mSwapchainFormat, + .samples = VK_SAMPLE_COUNT_1_BIT, + .loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, + .storeOp = VK_ATTACHMENT_STORE_OP_STORE, + .stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE, + .stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + }, + VkAttachmentDescription{ + .format = mDepthStencilFormat, + .samples = VK_SAMPLE_COUNT_1_BIT, + .loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, + .storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, + .stencilLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, + .stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, + }, + }; + + const VkAttachmentReference colorReference{ 0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL }; + const VkAttachmentReference depthReference{ 1, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL }; + + const VkSubpassDescription subpass{ + .pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, + .colorAttachmentCount = 1, + .pColorAttachments = &colorReference, + .pDepthStencilAttachment = &depthReference, + }; + + const VkSubpassDependency dependency{ + .srcSubpass = VK_SUBPASS_EXTERNAL, + .dstSubpass = 0, + .srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT, + .dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT, + .srcAccessMask = 0, + .dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT, + }; + + const VkRenderPassCreateInfo createInfo{ + .sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, + .attachmentCount = (uint32_t) attachments.size(), + .pAttachments = attachments.data(), + .subpassCount = 1, + .pSubpasses = &subpass, + .dependencyCount = 1, + .pDependencies = &dependency, + }; + requireSuccess(vkCreateRenderPass(mDevice, &createInfo, nullptr, &mRenderPass), "vkCreateRenderPass"); + } + + VkFormat WindowSurface::selectDepthStencilFormat() const { + for (VkFormat format : { VK_FORMAT_D24_UNORM_S8_UINT, VK_FORMAT_D32_SFLOAT_S8_UINT, VK_FORMAT_D16_UNORM_S8_UINT }) { + VkFormatProperties properties{}; + vkGetPhysicalDeviceFormatProperties(mGpu.physicalDevice(), format, &properties); + if (properties.optimalTilingFeatures & VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT) { + return format; + } + } + throw GpuError("selecting a depth-stencil format", VK_ERROR_FORMAT_NOT_SUPPORTED); + } + + void WindowSurface::createFrameSlots() { + VkCommandBuffer commandBuffers[kFramesInFlight] = {}; + const VkCommandBufferAllocateInfo allocateInfo{ + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = mGpu.commandPool(), + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = kFramesInFlight, + }; + requireSuccess(vkAllocateCommandBuffers(mDevice, &allocateInfo, commandBuffers), "vkAllocateCommandBuffers"); + + const VkSemaphoreCreateInfo semaphoreInfo{ .sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO }; + const VkFenceCreateInfo fenceInfo{ + .sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO, + .flags = VK_FENCE_CREATE_SIGNALED_BIT, + }; + for (uint32_t index = 0; index < kFramesInFlight; index++) { + FrameSlot& slot = mFrameSlots[index]; + slot.commandBuffer = commandBuffers[index]; + requireSuccess(vkCreateSemaphore(mDevice, &semaphoreInfo, nullptr, &slot.imageAvailable), "vkCreateSemaphore"); + requireSuccess(vkCreateFence(mDevice, &fenceInfo, nullptr, &slot.inFlight), "vkCreateFence"); + } + } + + void WindowSurface::destroyFrameSlots() { + for (FrameSlot& slot : mFrameSlots) { + if (slot.imageAvailable != VK_NULL_HANDLE) { + vkDestroySemaphore(mDevice, slot.imageAvailable, nullptr); + } + if (slot.inFlight != VK_NULL_HANDLE) { + vkDestroyFence(mDevice, slot.inFlight, nullptr); + } + if (slot.commandBuffer != VK_NULL_HANDLE) { + vkFreeCommandBuffers(mDevice, mGpu.commandPool(), 1, &slot.commandBuffer); + } + slot = {}; + } + } + + // Mailbox replaces the queued frame instead of waiting behind it; FIFO is the guaranteed fallback. + VkPresentModeKHR WindowSurface::selectPresentMode() const { + uint32_t count = 0; + vkGetPhysicalDeviceSurfacePresentModesKHR(mGpu.physicalDevice(), mSurface, &count, nullptr); + std::vector modes(count); + vkGetPhysicalDeviceSurfacePresentModesKHR(mGpu.physicalDevice(), mSurface, &count, modes.data()); + const bool hasMailbox = std::find(modes.begin(), modes.end(), VK_PRESENT_MODE_MAILBOX_KHR) != modes.end(); + return hasMailbox ? VK_PRESENT_MODE_MAILBOX_KHR : VK_PRESENT_MODE_FIFO_KHR; + } + + void WindowSurface::createSwapchain(VkExtent2D requested, VkSwapchainKHR previous) { + const VkSurfaceCapabilitiesKHR capabilities = capabilitiesOf(mGpu.physicalDevice(), mSurface); + + mSwapchainExtent = capabilities.currentExtent; + mSurfaceTransform = capabilities.currentTransform; + mSurfaceExtent = capabilities.currentExtent; + + VkSurfaceTransformFlagBitsKHR transform = capabilities.currentTransform; + const bool canRenderUnrotated = capabilities.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR; + if (transform != VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR && canRenderUnrotated) { + transform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR; + const bool quarterTurn = capabilities.currentTransform & + (VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR | VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR); + if (quarterTurn && mSwapchainExtent.width != kUndefinedExtent) { + std::swap(mSwapchainExtent.width, mSwapchainExtent.height); + } + } + if (mSwapchainExtent.width == kUndefinedExtent) { + mSwapchainExtent.width = + std::clamp(requested.width, capabilities.minImageExtent.width, capabilities.maxImageExtent.width); + mSwapchainExtent.height = + std::clamp(requested.height, capabilities.minImageExtent.height, capabilities.maxImageExtent.height); + } + + const VkPresentModeKHR presentMode = selectPresentMode(); + uint32_t imageCount = capabilities.minImageCount + (presentMode == VK_PRESENT_MODE_MAILBOX_KHR ? 1 : 0); + if (capabilities.maxImageCount > 0) { + imageCount = std::min(imageCount, capabilities.maxImageCount); + } + + const VkSwapchainCreateInfoKHR createInfo{ + .sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR, + .surface = mSurface, + .minImageCount = imageCount, + .imageFormat = mSwapchainFormat, + .imageColorSpace = VK_COLORSPACE_SRGB_NONLINEAR_KHR, + .imageExtent = mSwapchainExtent, + .imageArrayLayers = 1, + .imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT, + .imageSharingMode = VK_SHARING_MODE_EXCLUSIVE, + .preTransform = transform, + .compositeAlpha = mCompositeAlpha, + .presentMode = presentMode, + .clipped = VK_TRUE, + .oldSwapchain = previous, + }; + const VkResult created = vkCreateSwapchainKHR(mDevice, &createInfo, nullptr, &mSwapchain); + if (previous != VK_NULL_HANDLE) { + vkDestroySwapchainKHR(mDevice, previous, nullptr); + } + requireSuccess(created, "vkCreateSwapchainKHR"); + + createSwapchainImages(); + } + + // Each image owns its render-finished semaphore: presentation may still hold it after its frame slot is reused. + void WindowSurface::createSwapchainImages() { + uint32_t count = 0; + vkGetSwapchainImagesKHR(mDevice, mSwapchain, &count, nullptr); + mSwapchainImages.resize(count); + vkGetSwapchainImagesKHR(mDevice, mSwapchain, &count, mSwapchainImages.data()); + + createDepthStencil(); + + const VkSemaphoreCreateInfo semaphoreInfo{ .sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO }; + mSwapchainImageViews.resize(count); + mSwapchainFramebuffers.resize(count); + mRenderFinishedSemaphores.resize(count); + for (uint32_t index = 0; index < count; index++) { + const VkImageViewCreateInfo viewInfo{ + .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, + .image = mSwapchainImages[index], + .viewType = VK_IMAGE_VIEW_TYPE_2D, + .format = mSwapchainFormat, + .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }, + }; + requireSuccess(vkCreateImageView(mDevice, &viewInfo, nullptr, &mSwapchainImageViews[index]), "vkCreateImageView"); + + const std::array attachments = { mSwapchainImageViews[index], mDepthStencilView }; + const VkFramebufferCreateInfo framebufferInfo{ + .sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, + .renderPass = mRenderPass, + .attachmentCount = (uint32_t) attachments.size(), + .pAttachments = attachments.data(), + .width = mSwapchainExtent.width, + .height = mSwapchainExtent.height, + .layers = 1, + }; + requireSuccess( + vkCreateFramebuffer(mDevice, &framebufferInfo, nullptr, &mSwapchainFramebuffers[index]), "vkCreateFramebuffer" + ); + requireSuccess( + vkCreateSemaphore(mDevice, &semaphoreInfo, nullptr, &mRenderFinishedSemaphores[index]), "vkCreateSemaphore" + ); + } + } + + void WindowSurface::createDepthStencil() { + const VkImageCreateInfo imageInfo{ + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .imageType = VK_IMAGE_TYPE_2D, + .format = mDepthStencilFormat, + .extent = { mSwapchainExtent.width, mSwapchainExtent.height, 1 }, + .mipLevels = 1, + .arrayLayers = 1, + .samples = VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + }; + requireSuccess(vkCreateImage(mDevice, &imageInfo, nullptr, &mDepthStencilImage), "vkCreateImage"); + + VkMemoryRequirements requirements{}; + vkGetImageMemoryRequirements(mDevice, mDepthStencilImage, &requirements); + const VkMemoryAllocateInfo allocateInfo{ + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = requirements.size, + .memoryTypeIndex = mGpu.findMemoryType(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT), + }; + requireSuccess(vkAllocateMemory(mDevice, &allocateInfo, nullptr, &mDepthStencilMemory), "vkAllocateMemory"); + requireSuccess(vkBindImageMemory(mDevice, mDepthStencilImage, mDepthStencilMemory, 0), "vkBindImageMemory"); + + const VkImageViewCreateInfo viewInfo{ + .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, + .image = mDepthStencilImage, + .viewType = VK_IMAGE_VIEW_TYPE_2D, + .format = mDepthStencilFormat, + .subresourceRange = { VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT, 0, 1, 0, 1 }, + }; + requireSuccess(vkCreateImageView(mDevice, &viewInfo, nullptr, &mDepthStencilView), "vkCreateImageView"); + } + + void WindowSurface::destroyDepthStencil() { + if (mDepthStencilView != VK_NULL_HANDLE) { + vkDestroyImageView(mDevice, mDepthStencilView, nullptr); + } + if (mDepthStencilImage != VK_NULL_HANDLE) { + vkDestroyImage(mDevice, mDepthStencilImage, nullptr); + } + if (mDepthStencilMemory != VK_NULL_HANDLE) { + vkFreeMemory(mDevice, mDepthStencilMemory, nullptr); + } + mDepthStencilView = VK_NULL_HANDLE; + mDepthStencilImage = VK_NULL_HANDLE; + mDepthStencilMemory = VK_NULL_HANDLE; + } + + void WindowSurface::destroySwapchainResources() { + destroyDepthStencil(); + for (VkFramebuffer framebuffer : mSwapchainFramebuffers) { + vkDestroyFramebuffer(mDevice, framebuffer, nullptr); + } + for (VkImageView view : mSwapchainImageViews) { + vkDestroyImageView(mDevice, view, nullptr); + } + for (VkSemaphore semaphore : mRenderFinishedSemaphores) { + vkDestroySemaphore(mDevice, semaphore, nullptr); + } + mSwapchainFramebuffers.clear(); + mSwapchainImageViews.clear(); + mRenderFinishedSemaphores.clear(); + mSwapchainImages.clear(); + } + + void WindowSurface::destroySwapchain() { + destroySwapchainResources(); + if (mSwapchain != VK_NULL_HANDLE) { + vkDestroySwapchainKHR(mDevice, mSwapchain, nullptr); + } + mSwapchain = VK_NULL_HANDLE; + } + + void WindowSurface::waitForFramesInFlight() { + VkFence fences[kFramesInFlight] = {}; + for (uint32_t index = 0; index < kFramesInFlight; index++) { + fences[index] = mFrameSlots[index].inFlight; + } + vkWaitForFences(mDevice, kFramesInFlight, fences, VK_TRUE, UINT64_MAX); + } + + // Only frames using the old views must finish; the old swapchain is handed over so presentation has no gap. + void WindowSurface::recreateSwapchain(VkExtent2D requested) { + waitForFramesInFlight(); + vkQueueWaitIdle(mGpu.graphicsQueue()); + destroySwapchainResources(); + + VkSwapchainKHR previous = mSwapchain; + mSwapchain = VK_NULL_HANDLE; + createSwapchain(atLeastOnePixel(requested), previous); + mRequestedExtent = requested; + mSwapchainRecreations++; + } + + bool WindowSurface::swapchainMatches(VkExtent2D extent) const { + return mRequestedExtent.width == extent.width && mRequestedExtent.height == extent.height; + } + + bool WindowSurface::surfaceChanged() const { + VkSurfaceCapabilitiesKHR capabilities{}; + if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(mGpu.physicalDevice(), mSurface, &capabilities) != VK_SUCCESS) { + return true; + } + return capabilities.currentTransform != mSurfaceTransform || + capabilities.currentExtent.width != mSurfaceExtent.width || + capabilities.currentExtent.height != mSurfaceExtent.height; + } + + std::optional WindowSurface::beginFrame() { + const VkExtent2D extent = mSource.framebufferExtent(); + if (isEmpty(extent) || mSurface == VK_NULL_HANDLE) { + return std::nullopt; + } + if (!swapchainMatches(extent)) { + recreateSwapchain(extent); + } + + FrameSlot& slot = mFrameSlots[mCurrentSlot]; + VkResult acquired = VK_SUCCESS; + { + const ProfileScope scope(mProfiler, mAcquireSection); + vkWaitForFences(mDevice, 1, &slot.inFlight, VK_TRUE, UINT64_MAX); + acquired = + vkAcquireNextImageKHR(mDevice, mSwapchain, UINT64_MAX, slot.imageAvailable, VK_NULL_HANDLE, &mAcquiredImage); + } + if (acquired == VK_ERROR_OUT_OF_DATE_KHR) { + recreateSwapchain(extent); + return std::nullopt; + } + if (acquired != VK_SUBOPTIMAL_KHR) { + requireSuccess(acquired, "vkAcquireNextImageKHR"); + } + + requireSuccess(vkResetFences(mDevice, 1, &slot.inFlight), "vkResetFences"); + recordFrameStart(slot.commandBuffer, mAcquiredImage); + mFrameActive = true; + return GpuFrame{ slot.commandBuffer, mCurrentSlot, mSwapchainExtent }; + } + + void WindowSurface::recordFrameStart(VkCommandBuffer commandBuffer, uint32_t imageIndex) { + requireSuccess(vkResetCommandBuffer(commandBuffer, 0), "vkResetCommandBuffer"); + const VkCommandBufferBeginInfo beginInfo{ .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO }; + requireSuccess(vkBeginCommandBuffer(commandBuffer, &beginInfo), "vkBeginCommandBuffer"); + mFrameTimer->begin(commandBuffer); + + std::array clearValues{}; + clearValues[0].color = { { 0.0f, 0.0f, 0.0f, mTranslucent ? 0.0f : 1.0f } }; + clearValues[1].depthStencil = { 1.0f, 0 }; + + const VkRenderPassBeginInfo renderPassInfo{ + .sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, + .renderPass = mRenderPass, + .framebuffer = mSwapchainFramebuffers[imageIndex], + .renderArea = { .offset = { 0, 0 }, .extent = mSwapchainExtent }, + .clearValueCount = (uint32_t) clearValues.size(), + .pClearValues = clearValues.data(), + }; + vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE); + + const VkViewport viewport{ + .x = 0.0f, + .y = 0.0f, + .width = (float) mSwapchainExtent.width, + .height = (float) mSwapchainExtent.height, + .minDepth = 0.0f, + .maxDepth = 1.0f, + }; + vkCmdSetViewport(commandBuffer, 0, 1, &viewport); + + const VkRect2D scissor{ { 0, 0 }, mSwapchainExtent }; + vkCmdSetScissor(commandBuffer, 0, 1, &scissor); + } + + void WindowSurface::endFrame() { + if (!mFrameActive) { + return; + } + mFrameActive = false; + + const ProfileScope scope(mProfiler, mSubmitSection); + FrameSlot& slot = mFrameSlots[mCurrentSlot]; + vkCmdEndRenderPass(slot.commandBuffer); + mFrameTimer->end(slot.commandBuffer); + requireSuccess(vkEndCommandBuffer(slot.commandBuffer), "vkEndCommandBuffer"); + + VkSemaphore renderFinished = mRenderFinishedSemaphores[mAcquiredImage]; + const VkPipelineStageFlags waitStage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + const VkSubmitInfo submitInfo{ + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .waitSemaphoreCount = 1, + .pWaitSemaphores = &slot.imageAvailable, + .pWaitDstStageMask = &waitStage, + .commandBufferCount = 1, + .pCommandBuffers = &slot.commandBuffer, + .signalSemaphoreCount = 1, + .pSignalSemaphores = &renderFinished, + }; + requireSuccess(vkQueueSubmit(mGpu.graphicsQueue(), 1, &submitInfo, slot.inFlight), "vkQueueSubmit"); + + const VkPresentInfoKHR presentInfo{ + .sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR, + .waitSemaphoreCount = 1, + .pWaitSemaphores = &renderFinished, + .swapchainCount = 1, + .pSwapchains = &mSwapchain, + .pImageIndices = &mAcquiredImage, + }; + const VkResult presented = vkQueuePresentKHR(mGpu.graphicsQueue(), &presentInfo); + mCurrentSlot = (mCurrentSlot + 1) % kFramesInFlight; + + const bool outOfDate = presented == VK_ERROR_OUT_OF_DATE_KHR; + if (outOfDate || (presented == VK_SUBOPTIMAL_KHR && surfaceChanged())) { + recreateSwapchain(mSource.framebufferExtent()); + return; + } + if (presented != VK_SUBOPTIMAL_KHR) { + requireSuccess(presented, "vkQueuePresentKHR"); + } + } + + void WindowSurface::syncToSource() { + const VkExtent2D extent = mSource.framebufferExtent(); + if (isEmpty(extent) || mSurface == VK_NULL_HANDLE) { + return; + } + recreateSwapchain(extent); + } + + void WindowSurface::releaseSurface() { + if (mSurface == VK_NULL_HANDLE) { + return; + } + mGpu.waitIdle(); + destroySwapchain(); + mSwapchainExtent = { 0, 0 }; + vkDestroySurfaceKHR(mGpu.instance(), mSurface, nullptr); + mSurface = VK_NULL_HANDLE; + } + + void WindowSurface::recreateSurface() { + if (mSurface != VK_NULL_HANDLE) { + return; + } + createSurface(); + const VkExtent2D extent = mSource.framebufferExtent(); + createSwapchain(atLeastOnePixel(extent)); + mRequestedExtent = extent; + } +} diff --git a/GraphicApplication/src/gpu/public/GpuDevice.hpp b/GraphicApplication/src/gpu/public/GpuDevice.hpp new file mode 100644 index 00000000..82e61dcf --- /dev/null +++ b/GraphicApplication/src/gpu/public/GpuDevice.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include "RenderTargetRegistry.hpp" + +#include + +#include + +namespace tp { + + // The Vulkan instance, device and graphics queue shared by every window of the process. + class GpuDevice { + public: + explicit GpuDevice(const std::vector& surfaceExtensions); + ~GpuDevice(); + + GpuDevice(const GpuDevice&) = delete; + GpuDevice& operator=(const GpuDevice&) = delete; + + [[nodiscard]] VkInstance instance() const { return mInstance; } + [[nodiscard]] VkPhysicalDevice physicalDevice() const { return mPhysicalDevice; } + [[nodiscard]] VkDevice device() const { return mDevice; } + [[nodiscard]] VkQueue graphicsQueue() const { return mGraphicsQueue; } + [[nodiscard]] uint32_t graphicsQueueFamily() const { return mGraphicsQueueFamily; } + [[nodiscard]] VkCommandPool commandPool() const { return mCommandPool; } + [[nodiscard]] const char* deviceName() const { return mProperties.deviceName; } + + [[nodiscard]] bool canPresent(VkSurfaceKHR surface) const; + [[nodiscard]] uint32_t findMemoryType(uint32_t typeFilter, VkMemoryPropertyFlags properties) const; + + [[nodiscard]] VkCommandBuffer beginOneShotCommands() const; + void endOneShotCommands(VkCommandBuffer commandBuffer) const; + + void waitIdle() const; + + [[nodiscard]] RenderTargetRegistry& renderTargets() { return mRenderTargets; } + + private: + void createInstance(const std::vector& surfaceExtensions); + void selectPhysicalDevice(); + void createLogicalDevice(); + void createCommandPool(); + + VkInstance mInstance = VK_NULL_HANDLE; + VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE; + VkPhysicalDeviceProperties mProperties{}; + VkDevice mDevice = VK_NULL_HANDLE; + uint32_t mGraphicsQueueFamily = UINT32_MAX; + VkQueue mGraphicsQueue = VK_NULL_HANDLE; + VkCommandPool mCommandPool = VK_NULL_HANDLE; + RenderTargetRegistry mRenderTargets; + }; +} diff --git a/GraphicApplication/src/gpu/public/GpuError.hpp b/GraphicApplication/src/gpu/public/GpuError.hpp new file mode 100644 index 00000000..554ebee3 --- /dev/null +++ b/GraphicApplication/src/gpu/public/GpuError.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include +#include + +namespace tp { + + // A Vulkan call failed in a way the renderer cannot recover from. + class GpuError : public std::runtime_error { + public: + GpuError(const char* operation, VkResult result) : + std::runtime_error(std::string(operation) + " failed: VkResult " + std::to_string((int) result)), + mResult(result) {} + + [[nodiscard]] VkResult result() const { return mResult; } + + private: + VkResult mResult; + }; + + inline void requireSuccess(VkResult result, const char* operation) { + if (result != VK_SUCCESS) { + throw GpuError(operation, result); + } + } +} diff --git a/GraphicApplication/src/gpu/public/RenderTargetRegistry.hpp b/GraphicApplication/src/gpu/public/RenderTargetRegistry.hpp new file mode 100644 index 00000000..aa22d56f --- /dev/null +++ b/GraphicApplication/src/gpu/public/RenderTargetRegistry.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include +#include + +namespace tp { + + struct RenderTargetEntry { + VkImageView view = VK_NULL_HANDLE; + VkSampler sampler = VK_NULL_HANDLE; + int width = 0; + int height = 0; + VkImage image = VK_NULL_HANDLE; + }; + + class RenderTargetRegistry { + public: + uint32_t registerTarget(const RenderTargetEntry& entry); + void updateTarget(uint32_t id, const RenderTargetEntry& entry); + void unregisterTarget(uint32_t id); + [[nodiscard]] bool resolve(uint32_t id, RenderTargetEntry& out) const; + + private: + uint32_t mNextId = 1; + std::unordered_map mEntries; + }; +} diff --git a/GraphicApplication/src/gpu/public/WindowSurface.hpp b/GraphicApplication/src/gpu/public/WindowSurface.hpp new file mode 100644 index 00000000..46832c15 --- /dev/null +++ b/GraphicApplication/src/gpu/public/WindowSurface.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include "GpuDevice.hpp" + +#include + +#include +#include +#include +#include + +namespace tp { + + class GpuTimer; + class Profiler; + + // The native window a WindowSurface presents into. + class VulkanSurfaceSource { + public: + virtual ~VulkanSurfaceSource() = default; + + [[nodiscard]] virtual VkSurfaceKHR createSurface(VkInstance instance) const = 0; + [[nodiscard]] virtual VkExtent2D framebufferExtent() const = 0; + [[nodiscard]] virtual bool wantsTranslucency() const = 0; + virtual void onTranslucencyResolved(bool translucent) = 0; + }; + + // The frame being recorded: renderers record into commandBuffer and key per-frame resources by slot. + struct GpuFrame { + VkCommandBuffer commandBuffer = VK_NULL_HANDLE; + uint32_t slot = 0; + VkExtent2D extent{}; + }; + + // Presentation of one window: surface, swapchain, render pass and frame synchronisation. + class WindowSurface { + public: + static constexpr uint32_t kFramesInFlight = 2; + + WindowSurface(GpuDevice& gpu, VulkanSurfaceSource& source, Profiler& profiler); + ~WindowSurface(); + + WindowSurface(const WindowSurface&) = delete; + WindowSurface& operator=(const WindowSurface&) = delete; + + [[nodiscard]] GpuDevice& gpu() const { return mGpu; } + [[nodiscard]] VkRenderPass renderPass() const { return mRenderPass; } + [[nodiscard]] uint32_t imageCount() const { return (uint32_t) mSwapchainImages.size(); } + [[nodiscard]] bool isTranslucent() const { return mTranslucent; } + [[nodiscard]] bool hasSurface() const { return mSurface != VK_NULL_HANDLE; } + [[nodiscard]] uint64_t swapchainRecreations() const { return mSwapchainRecreations; } + + [[nodiscard]] std::optional beginFrame(); + void endFrame(); + + void syncToSource(); + void releaseSurface(); + void recreateSurface(); + + private: + struct FrameSlot { + VkCommandBuffer commandBuffer = VK_NULL_HANDLE; + VkSemaphore imageAvailable = VK_NULL_HANDLE; + VkFence inFlight = VK_NULL_HANDLE; + }; + + void createSurface(); + void selectCompositeAlpha(); + void createRenderPass(); + void createFrameSlots(); + void destroyFrameSlots(); + + [[nodiscard]] VkPresentModeKHR selectPresentMode() const; + [[nodiscard]] VkFormat selectDepthStencilFormat() const; + [[nodiscard]] bool surfaceChanged() const; + [[nodiscard]] bool swapchainMatches(VkExtent2D extent) const; + + void createSwapchain(VkExtent2D requested, VkSwapchainKHR previous = VK_NULL_HANDLE); + void createSwapchainImages(); + void createDepthStencil(); + void destroyDepthStencil(); + void destroySwapchainResources(); + void destroySwapchain(); + void recreateSwapchain(VkExtent2D requested); + void waitForFramesInFlight(); + void recordFrameStart(VkCommandBuffer commandBuffer, uint32_t imageIndex); + + GpuDevice& mGpu; + VkDevice mDevice; + VulkanSurfaceSource& mSource; + Profiler& mProfiler; + + VkSurfaceKHR mSurface = VK_NULL_HANDLE; + VkCompositeAlphaFlagBitsKHR mCompositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; + bool mTranslucent = false; + VkSurfaceTransformFlagBitsKHR mSurfaceTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR; + VkExtent2D mSurfaceExtent{ 0, 0 }; + VkExtent2D mRequestedExtent{ 0, 0 }; + + VkFormat mSwapchainFormat{}; + VkFormat mDepthStencilFormat{}; + VkRenderPass mRenderPass = VK_NULL_HANDLE; + + VkSwapchainKHR mSwapchain = VK_NULL_HANDLE; + VkExtent2D mSwapchainExtent{}; + std::vector mSwapchainImages; + std::vector mSwapchainImageViews; + std::vector mSwapchainFramebuffers; + std::vector mRenderFinishedSemaphores; + uint64_t mSwapchainRecreations = 0; + + VkImage mDepthStencilImage = VK_NULL_HANDLE; + VkDeviceMemory mDepthStencilMemory = VK_NULL_HANDLE; + VkImageView mDepthStencilView = VK_NULL_HANDLE; + + FrameSlot mFrameSlots[kFramesInFlight]; + uint32_t mCurrentSlot = 0; + uint32_t mAcquiredImage = 0; + bool mFrameActive = false; + + std::unique_ptr mFrameTimer; + uint32_t mAcquireSection = 0; + uint32_t mSubmitSection = 0; + }; +} diff --git a/GraphicApplication/src/input_layer/private/InputCoalescer.cpp b/GraphicApplication/src/input_layer/private/InputCoalescer.cpp new file mode 100644 index 00000000..a931af2d --- /dev/null +++ b/GraphicApplication/src/input_layer/private/InputCoalescer.cpp @@ -0,0 +1,85 @@ +#include "InputCoalescer.hpp" + +using namespace tp; + +namespace { + bool isGestureUpdate(const InputEvent& event) { + const auto* changed = std::get_if(&event); + return changed && changed->gesture.phase == GesturePhase::Update; + } +} + +void InputCoalescer::post(const InputEvent& event) { + if (mTap) { + mTap->post(event); + } + mStatistics.received++; + + if (const auto* sampled = std::get_if(&event)) { + mSample = sampled->sample; + return; + } + if (merge(event)) { + return; + } + mEvents.push_back({ event, mSample }); +} + +bool InputCoalescer::merge(const InputEvent& event) { + if (mEvents.empty()) { + return false; + } + Pending& previous = mEvents.back(); + + if (const auto* moved = std::get_if(&event)) { + auto* last = std::get_if(&previous.event); + if (!last || mPolicies.pointerMotion == DeliveryPolicy::Every) { + return false; + } + last->position = + mPolicies.pointerMotion == DeliveryPolicy::Latest ? moved->position : last->position + moved->position; + previous.sample = mSample; + return true; + } + + if (const auto* scrolled = std::get_if(&event)) { + auto* last = std::get_if(&previous.event); + if (!last || last->source != scrolled->source || mPolicies.scroll == DeliveryPolicy::Every) { + return false; + } + last->delta = mPolicies.scroll == DeliveryPolicy::Latest ? scrolled->delta : last->delta + scrolled->delta; + return true; + } + + if (isGestureUpdate(event) && isGestureUpdate(previous.event)) { + accumulate(std::get(previous.event).gesture, std::get(event).gesture); + return true; + } + return false; +} + +bool InputCoalescer::carriesSample(const InputEvent& event) { + return std::holds_alternative(event) || std::holds_alternative(event); +} + +bool InputCoalescer::isEmptyScroll(const InputEvent& event) { + const auto* scrolled = std::get_if(&event); + return scrolled && scrolled->delta == Vec2F{ 0, 0 }; +} + +void InputCoalescer::deliver(InputSink& sink) { + std::vector events; + events.swap(mEvents); + + for (const Pending& pending : events) { + if (isEmptyScroll(pending.event)) { + continue; + } + if (carriesSample(pending.event) && !(pending.sample == mDeliveredSample)) { + mDeliveredSample = pending.sample; + sink.post(PointerSampled{ pending.sample }); + } + mStatistics.delivered++; + sink.post(pending.event); + } +} diff --git a/GraphicApplication/src/input_layer/private/InputRecording.cpp b/GraphicApplication/src/input_layer/private/InputRecording.cpp new file mode 100644 index 00000000..6db8ca08 --- /dev/null +++ b/GraphicApplication/src/input_layer/private/InputRecording.cpp @@ -0,0 +1,413 @@ +#include "InputRecording.hpp" + +#include +#include +#include +#include +#include +#include + +namespace tp { + + namespace { + const char* const kDeviceNames[] = { "mouse", "finger", "stylus" }; + const char* const kTipNames[] = { "pen", "eraser", "brush", "pencil", "airbrush" }; + const char* const kButtonNames[] = { "left", "right", "middle", "back", "forward" }; + const char* const kScrollSourceNames[] = { "wheel", "touchpad" }; + const char* const kKeyActionNames[] = { "press", "release", "repeat" }; + const char* const kGesturePhaseNames[] = { "begin", "update", "end" }; + + template + void writeName(std::ostream& out, const char* const (&names)[Count], Enum value) { + out << ' ' << names[(size_t) value]; + } + + template + bool readName(std::istream& in, const char* const (&names)[Count], Enum& value) { + std::string name; + in >> name; + for (size_t index = 0; index < Count; index++) { + if (name == names[index]) { + value = (Enum) index; + return true; + } + } + return false; + } + + void writeVector(std::ostream& out, Vec2F value) { out << ' ' << value.x << ' ' << value.y; } + + void readVector(std::istream& in, Vec2F& value) { in >> value.x >> value.y; } + + std::string escapedText(const std::string& text) { + std::string escaped; + for (const unsigned char byte : text) { + if (byte <= ' ' || byte == '%' || byte == 0x7f) { + char code[4]; + std::snprintf(code, sizeof(code), "%%%02X", byte); + escaped += code; + } else { + escaped += (char) byte; + } + } + return escaped; + } + + std::optional unescapedText(const std::string& escaped) { + std::string text; + for (size_t index = 0; index < escaped.size(); index++) { + if (escaped[index] != '%') { + text += escaped[index]; + continue; + } + if (index + 2 >= escaped.size()) { + return std::nullopt; + } + text += (char) std::stoi(escaped.substr(index + 1, 2), nullptr, 16); + index += 2; + } + return text; + } + + // Recording format of each InputEvent alternative. A missing specialization fails to compile. + template + struct RecordedFormat; + + template <> + struct RecordedFormat { + static constexpr const char* kName = "sample"; + + static void write(std::ostream& out, const PointerSampled& event) { + const PointerSample& sample = event.sample; + writeName(out, kDeviceNames, sample.device); + writeName(out, kTipNames, sample.tip); + out << ' ' << sample.pressure; + writeVector(out, sample.tilt); + out << ' ' << sample.rotation << ' ' << sample.distance << ' ' << sample.timeMs; + } + + static bool read(std::istream& in, PointerSampled& event) { + PointerSample& sample = event.sample; + if (!readName(in, kDeviceNames, sample.device) || !readName(in, kTipNames, sample.tip)) { + return false; + } + in >> sample.pressure; + readVector(in, sample.tilt); + in >> sample.rotation >> sample.distance >> sample.timeMs; + return true; + } + }; + + template <> + struct RecordedFormat { + static constexpr const char* kName = "move"; + + static void write(std::ostream& out, const PointerMoved& event) { writeVector(out, event.position); } + + static bool read(std::istream& in, PointerMoved& event) { + readVector(in, event.position); + return true; + } + }; + + template <> + struct RecordedFormat { + static constexpr const char* kName = "leave"; + + static void write(std::ostream&, const PointerLeft&) {} + + static bool read(std::istream&, PointerLeft&) { return true; } + }; + + template <> + struct RecordedFormat { + static constexpr const char* kName = "button"; + + static void write(std::ostream& out, const PointerButtonChanged& event) { + writeName(out, kButtonNames, event.button); + out << ' ' << (int) event.pressed; + } + + static bool read(std::istream& in, PointerButtonChanged& event) { + int pressed = 0; + if (!readName(in, kButtonNames, event.button)) { + return false; + } + in >> pressed; + event.pressed = pressed != 0; + return true; + } + }; + + template <> + struct RecordedFormat { + static constexpr const char* kName = "scroll"; + + static void write(std::ostream& out, const Scrolled& event) { + writeName(out, kScrollSourceNames, event.source); + writeVector(out, event.delta); + } + + static bool read(std::istream& in, Scrolled& event) { + if (!readName(in, kScrollSourceNames, event.source)) { + return false; + } + readVector(in, event.delta); + return true; + } + }; + + template <> + struct RecordedFormat { + static constexpr const char* kName = "scroll-end"; + + static void write(std::ostream&, const ScrollEnded&) {} + + static bool read(std::istream&, ScrollEnded&) { return true; } + }; + + template <> + struct RecordedFormat { + static constexpr const char* kName = "key"; + + static void write(std::ostream& out, const KeyChanged& event) { + out << ' ' << (unsigned) event.key; + writeName(out, kKeyActionNames, event.action); + } + + static bool read(std::istream& in, KeyChanged& event) { + unsigned code = 0; + in >> code; + if (code >= kKeyCodeLimit) { + return false; + } + event.key = (Key) code; + return readName(in, kKeyActionNames, event.action); + } + }; + + template <> + struct RecordedFormat { + static constexpr const char* kName = "text"; + + static void write(std::ostream& out, const TextEntered& event) { out << ' ' << escapedText(event.utf8); } + + static bool read(std::istream& in, TextEntered& event) { + std::string escaped; + in >> escaped; + const std::optional text = unescapedText(escaped); + if (!text) { + return false; + } + event.utf8 = *text; + return true; + } + }; + + template <> + struct RecordedFormat { + static constexpr const char* kName = "focus"; + + static void write(std::ostream& out, const KeyboardFocusChanged& event) { out << ' ' << (int) event.focused; } + + static bool read(std::istream& in, KeyboardFocusChanged& event) { + int focused = 0; + in >> focused; + event.focused = focused != 0; + return true; + } + }; + + template <> + struct RecordedFormat { + static constexpr const char* kName = "gesture"; + + static void write(std::ostream& out, const GestureChanged& event) { + const TouchGesture& gesture = event.gesture; + writeName(out, kGesturePhaseNames, gesture.phase); + writeVector(out, gesture.center); + writeVector(out, gesture.pan); + out << ' ' << gesture.scale << ' ' << gesture.rotation << ' ' << gesture.tapFingers; + } + + static bool read(std::istream& in, GestureChanged& event) { + TouchGesture& gesture = event.gesture; + if (!readName(in, kGesturePhaseNames, gesture.phase)) { + return false; + } + readVector(in, gesture.center); + readVector(in, gesture.pan); + in >> gesture.scale >> gesture.rotation >> gesture.tapFingers; + return true; + } + }; + + template + std::optional readEventOf(const std::string& name, std::istream& in, std::index_sequence) { + std::optional result; + const auto tryAlternative = [&]() { + using Event = std::variant_alternative_t; + if (result || name != RecordedFormat::kName) { + return; + } + Event event; + if (RecordedFormat::read(in, event)) { + result = event; + } + }; + (tryAlternative.template operator()(), ...); + return result; + } + + std::optional readEvent(const std::string& name, std::istream& in) { + return readEventOf(name, in, std::make_index_sequence>{}); + } + + void writeEvent(std::ostream& out, const InputEvent& event) { + std::visit( + [&out](const Event& alternative) { + out << RecordedFormat::kName; + RecordedFormat::write(out, alternative); + }, + event + ); + } + + bool isPointerActivity(const InputEvent& event) { + return std::holds_alternative(event) || std::holds_alternative(event) || + std::holds_alternative(event) || std::holds_alternative(event); + } + + double steadyMs() { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return std::chrono::duration(now).count(); + } + } + + std::string InputRecording::serialize() const { + std::ostringstream text; + text << std::setprecision(9); + text << kHeader << "\n"; + text << "window " << windowSize.x << ' ' << windowSize.y << "\n"; + + for (const RecordedInput& recorded : events) { + text << std::fixed << std::setprecision(3) << recorded.timeMs << ' ' << std::defaultfloat << std::setprecision(9); + writeEvent(text, recorded.event); + text << "\n"; + } + return text.str(); + } + + std::optional InputRecording::parse(const std::string& text) { + std::istringstream input(text); + std::string line; + if (!std::getline(input, line) || line != kHeader) { + return std::nullopt; + } + + InputRecording recording; + while (std::getline(input, line)) { + if (line.empty()) { + continue; + } + + std::istringstream fields(line); + std::string first; + fields >> first; + if (first == "window") { + readVector(fields, recording.windowSize); + continue; + } + + std::string name; + fields >> name; + const std::optional event = readEvent(name, fields); + if (!event || fields.fail()) { + return std::nullopt; + } + recording.events.push_back({ std::stod(first), *event }); + } + return recording; + } + + bool InputRecording::save(const std::string& path) const { + std::ofstream file(path); + if (!file) { + return false; + } + file << serialize(); + return (bool) file; + } + + std::optional InputRecording::load(const std::string& path) { + std::ifstream file(path); + if (!file) { + return std::nullopt; + } + std::stringstream text; + text << file.rdbuf(); + return parse(text.str()); + } + + void InputRecording::replayInto(InputSink& sink) const { + for (const RecordedInput& recorded : events) { + sink.post(recorded.event); + } + } + + InputRecording InputRecording::translated(Vec2F offset) const { + InputRecording moved = *this; + for (RecordedInput& recorded : moved.events) { + if (auto* pointer = std::get_if(&recorded.event)) { + pointer->position += offset; + } + if (auto* gesture = std::get_if(&recorded.event)) { + gesture->gesture.center += offset; + } + } + return moved; + } + + InputRecording InputRecording::slicedToPointerActivity() const { + InputRecording sliced; + sliced.windowSize = windowSize; + + size_t first = events.size(); + size_t last = 0; + for (size_t index = 0; index < events.size(); index++) { + if (!std::holds_alternative(events[index].event)) { + continue; + } + first = std::min(first, index); + last = index; + } + if (first >= events.size()) { + return sliced; + } + + while (first > 0 && isPointerActivity(events[first - 1].event) && + !std::holds_alternative(events[first - 1].event)) { + first--; + } + + const double origin = events[first].timeMs; + for (size_t index = first; index <= last; index++) { + if (!isPointerActivity(events[index].event)) { + continue; + } + sliced.events.push_back({ events[index].timeMs - origin, events[index].event }); + } + return sliced; + } + + InputRecorder::InputRecorder(Clock clock) : + mClock(clock ? std::move(clock) : Clock(steadyMs)) {} + + void InputRecorder::post(const InputEvent& event) { + const double now = mClock(); + if (mStartMs < 0) { + mStartMs = now; + } + mRecording.events.push_back({ now - mStartMs, event }); + } +} diff --git a/GraphicApplication/src/input_layer/private/InputState.cpp b/GraphicApplication/src/input_layer/private/InputState.cpp new file mode 100644 index 00000000..51e052c8 --- /dev/null +++ b/GraphicApplication/src/input_layer/private/InputState.cpp @@ -0,0 +1,134 @@ +#include "InputState.hpp" + +using namespace tp; + +namespace { + void press(ButtonState& state) { + if (!state.down) { + state.pressed = true; + } + state.down = true; + } + + void release(ButtonState& state) { + if (state.down) { + state.released = true; + } + state.down = false; + } +} + +halnf InputState::pointerPressure() const { + if (mPointerSample.isStylus()) { + return mPointerSample.pressure; + } + return button(PointerButton::Left).down ? 1.0f : 0.0f; +} + +void InputState::beginFrame(std::vector events) { + mPreviousPointer = mPointer; + mScrollDelta = { 0, 0 }; + mContinuousScroll = false; + mScrollEnded = false; + + for (ButtonState& state : mPointerButtons) { + state.pressed = false; + state.released = false; + } + for (ButtonState& state : mKeys) { + state.pressed = false; + state.released = false; + } + + mFrameEvents = std::move(events); + for (const InputEvent& event : mFrameEvents) { + apply(event); + } +} + +void InputState::apply(const InputEvent& event) { + std::visit( + InputEventVisitor{ + [this](const PointerSampled& sampled) { mPointerSample = sampled.sample; }, + [this](const PointerMoved& moved) { + mPointer = moved.position; + mPointerInside = true; + }, + [this](const PointerLeft&) { mPointerInside = false; }, + [this](const PointerButtonChanged& changed) { + ButtonState& state = mPointerButtons[(size_t) changed.button]; + changed.pressed ? press(state) : release(state); + }, + [this](const Scrolled& scrolled) { + mScrollDelta += scrolled.delta; + mContinuousScroll = scrolled.source == ScrollSource::Touchpad; + }, + [this](const ScrollEnded&) { mScrollEnded = true; }, + [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::Repeat: break; + } + }, + [](const TextEntered&) {}, + [](const KeyboardFocusChanged&) {}, + [](const GestureChanged&) {}, + }, + event + ); +} + +void InputQueue::post(const InputEvent& event) { + if (std::holds_alternative(event)) { + releaseHeldPointerButtons(); + } + if (const auto* focus = std::get_if(&event); focus && !focus->focused) { + releaseHeldKeys(); + } + if (!tracksHeldInput(event)) { + return; + } + mPendingEvents.push_back(event); +} + +bool InputQueue::tracksHeldInput(const InputEvent& event) { + if (const auto* changed = std::get_if(&event)) { + if (changed->pressed) { + mHeldPointerButtons.insert(changed->button); + return true; + } + return mHeldPointerButtons.erase(changed->button) > 0; + } + if (const auto* changed = std::get_if(&event)) { + if (changed->action == KeyAction::Press) { + mHeldKeys.insert(changed->key); + } else if (changed->action == KeyAction::Release) { + mHeldKeys.erase(changed->key); + } + } + return true; +} + +void InputQueue::releaseHeldPointerButtons() { + const std::set held = std::move(mHeldPointerButtons); + mHeldPointerButtons.clear(); + for (PointerButton button : held) { + mPendingEvents.push_back(PointerButtonChanged{ button, false }); + } +} + +void InputQueue::releaseHeldKeys() { + const std::set held = std::move(mHeldKeys); + mHeldKeys.clear(); + for (Key key : held) { + mPendingEvents.push_back(KeyChanged{ key, KeyAction::Release }); + } +} + +void InputQueue::applyPendingEvents() { + std::vector events; + events.swap(mPendingEvents); + mState.beginFrame(std::move(events)); +} diff --git a/GraphicApplication/src/input_layer/private/TouchGesture.cpp b/GraphicApplication/src/input_layer/private/TouchGesture.cpp new file mode 100644 index 00000000..fc69ec84 --- /dev/null +++ b/GraphicApplication/src/input_layer/private/TouchGesture.cpp @@ -0,0 +1,122 @@ +#include "TouchGesture.hpp" + +#include +#include +#include + +namespace tp { + + namespace { + constexpr halnf kMinimumSpan = 1.0f; + + } + + void TouchGestureTracker::track(const TouchPoint& first, const TouchPoint& second) { + const Vec2F offset = second.position - first.position; + mFirst = first.id; + mSecond = second.id; + mCenter = (first.position + second.position) * 0.5f; + mDistance = std::sqrt(offset.x * offset.x + offset.y * offset.y); + mAngle = std::atan2(offset.y, offset.x); + } + + void TouchGestureTracker::followTap(const std::vector& touches, uint32_t timeMs) { + if (mTapStarts.empty() && !touches.empty()) { + mTapStartMs = timeMs; + mTapStillPossible = true; + } + for (const TouchPoint& touch : touches) { + auto start = std::find_if(mTapStarts.begin(), mTapStarts.end(), [&](const TouchPoint& known) { + return known.id == touch.id; + }); + if (start == mTapStarts.end()) { + mTapStarts.push_back(touch); + continue; + } + const Vec2F travel = touch.position - start->position; + if (travel.x * travel.x + travel.y * travel.y > kTapMaximumTravel * kTapMaximumTravel) { + mTapStillPossible = false; + } + } + } + + int32_t TouchGestureTracker::finishedTapFingers(uint32_t timeMs) const { + const bool quick = timeMs - mTapStartMs <= kTapMaximumMs; + return mTapStillPossible && quick && mTapStarts.size() >= 2 ? (int32_t) mTapStarts.size() : 0; + } + + void TouchGestureTracker::update( + const std::vector& touches, std::vector& gestures, uint32_t timeMs + ) { + followTap(touches, timeMs); + if (touches.empty()) { + const int32_t tapFingers = finishedTapFingers(timeMs); + mTapStarts.clear(); + cancel(gestures); + if (!gestures.empty() && gestures.back().phase == GesturePhase::End) { + gestures.back().tapFingers = tapFingers; + } + return; + } + if (touches.size() < 2) { + if (finishedTapFingers(timeMs) == 0) { + cancel(gestures); + } + return; + } + + const TouchPoint& first = touches[0]; + const TouchPoint& second = touches[1]; + + if (!mActive) { + track(first, second); + mActive = true; + gestures.push_back({ GesturePhase::Begin, mCenter }); + return; + } + + if (first.id != mFirst || second.id != mSecond) { + track(first, second); + return; + } + + const Vec2F previousCenter = mCenter; + const halnf previousDistance = mDistance; + const halnf previousAngle = mAngle; + track(first, second); + + TouchGesture gesture; + gesture.phase = GesturePhase::Update; + gesture.center = mCenter; + gesture.pan = mCenter - previousCenter; + gesture.scale = previousDistance >= kMinimumSpan && mDistance >= kMinimumSpan ? mDistance / previousDistance : 1; + gesture.rotation = wrapAngle(mAngle - previousAngle); + gestures.push_back(gesture); + } + + void TouchGestureTracker::cancel(std::vector& gestures) { + if (!mActive) { + return; + } + mActive = false; + gestures.push_back({ GesturePhase::End, mCenter }); + } + + halnf wrapAngle(halnf radians) { + constexpr halnf pi = std::numbers::pi_v; + while (radians > pi) { + radians -= 2 * pi; + } + while (radians < -pi) { + radians += 2 * pi; + } + return radians; + } + + void accumulate(TouchGesture& into, const TouchGesture& next) { + into.center = next.center; + into.pan += next.pan; + into.scale *= next.scale; + into.rotation = wrapAngle(into.rotation + next.rotation); + } +} diff --git a/GraphicApplication/src/input_layer/public/InputCoalescer.hpp b/GraphicApplication/src/input_layer/public/InputCoalescer.hpp new file mode 100644 index 00000000..44640e4d --- /dev/null +++ b/GraphicApplication/src/input_layer/public/InputCoalescer.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include "InputSink.hpp" + +#include +#include + +namespace tp { + + enum class DeliveryPolicy { Every, Latest, Accumulate }; + + struct InputDeliveryPolicies { + DeliveryPolicy pointerMotion = DeliveryPolicy::Latest; + DeliveryPolicy scroll = DeliveryPolicy::Accumulate; + }; + + struct CoalescingStatistics { + uint64_t received = 0; + uint64_t delivered = 0; + }; + + // Backends post raw events here; they are batched and delivered once per frame. + class InputCoalescer final : public InputSink { + public: + void post(const InputEvent& event) override; + + void setTap(InputSink* tap) { mTap = tap; } + void setPolicies(const InputDeliveryPolicies& policies) { mPolicies = policies; } + + [[nodiscard]] bool isEmpty() const { return mEvents.empty(); } + void deliver(InputSink& sink); + + [[nodiscard]] const CoalescingStatistics& statistics() const { return mStatistics; } + + private: + struct Pending { + InputEvent event; + PointerSample sample; + }; + + [[nodiscard]] bool merge(const InputEvent& event); + [[nodiscard]] static bool carriesSample(const InputEvent& event); + [[nodiscard]] static bool isEmptyScroll(const InputEvent& event); + + InputSink* mTap = nullptr; + InputDeliveryPolicies mPolicies; + std::vector mEvents; + PointerSample mSample; + PointerSample mDeliveredSample; + CoalescingStatistics mStatistics; + }; +} diff --git a/GraphicApplication/src/input_layer/public/InputCodes.hpp b/GraphicApplication/src/input_layer/public/InputCodes.hpp new file mode 100644 index 00000000..372baf57 --- /dev/null +++ b/GraphicApplication/src/input_layer/public/InputCodes.hpp @@ -0,0 +1,137 @@ +#pragma once + +#include +#include + +namespace tp { + + enum class PointerButton : uint8_t { Left, Right, Middle, Back, Forward }; + + inline constexpr size_t kPointerButtonCount = 5; + + enum class Key : uint16_t { + Space = 32, + Apostrophe = 39, + Comma = 44, + Minus = 45, + Period = 46, + Slash = 47, + Digit0 = 48, + Digit1 = 49, + Digit2 = 50, + Digit3 = 51, + Digit4 = 52, + Digit5 = 53, + Digit6 = 54, + Digit7 = 55, + Digit8 = 56, + Digit9 = 57, + Semicolon = 59, + Equal = 61, + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + LeftBracket = 91, + Backslash = 92, + RightBracket = 93, + GraveAccent = 96, + World1 = 161, + World2 = 162, + + Escape = 256, + Enter = 257, + Tab = 258, + Backspace = 259, + Insert = 260, + Delete = 261, + Right = 262, + Left = 263, + Down = 264, + Up = 265, + PageUp = 266, + PageDown = 267, + Home = 268, + End = 269, + CapsLock = 280, + ScrollLock = 281, + NumLock = 282, + PrintScreen = 283, + Pause = 284, + F1 = 290, + F2 = 291, + F3 = 292, + F4 = 293, + F5 = 294, + F6 = 295, + F7 = 296, + F8 = 297, + F9 = 298, + F10 = 299, + F11 = 300, + F12 = 301, + F13 = 302, + F14 = 303, + F15 = 304, + F16 = 305, + F17 = 306, + F18 = 307, + F19 = 308, + F20 = 309, + F21 = 310, + F22 = 311, + F23 = 312, + F24 = 313, + F25 = 314, + Keypad0 = 320, + Keypad1 = 321, + Keypad2 = 322, + Keypad3 = 323, + Keypad4 = 324, + Keypad5 = 325, + Keypad6 = 326, + Keypad7 = 327, + Keypad8 = 328, + Keypad9 = 329, + KeypadDecimal = 330, + KeypadDivide = 331, + KeypadMultiply = 332, + KeypadSubtract = 333, + KeypadAdd = 334, + KeypadEnter = 335, + KeypadEqual = 336, + LeftShift = 340, + LeftControl = 341, + LeftAlt = 342, + LeftSuper = 343, + RightShift = 344, + RightControl = 345, + RightAlt = 346, + RightSuper = 347, + Menu = 348, + }; + + inline constexpr size_t kKeyCodeLimit = 349; +} diff --git a/GraphicApplication/src/input_layer/public/InputEvent.hpp b/GraphicApplication/src/input_layer/public/InputEvent.hpp new file mode 100644 index 00000000..6ba8a4d2 --- /dev/null +++ b/GraphicApplication/src/input_layer/public/InputEvent.hpp @@ -0,0 +1,93 @@ +#pragma once + +#include "InputCodes.hpp" +#include "PointerSample.hpp" +#include "TouchGesture.hpp" +#include "Vec.hpp" + +#include +#include + +namespace tp { + + enum class ScrollSource { Wheel, Touchpad }; + + enum class KeyAction { Press, Release, Repeat }; + + struct PointerSampled { + PointerSample sample; + + [[nodiscard]] bool operator==(const PointerSampled& other) const = default; + }; + + struct PointerMoved { + Vec2F position = { 0, 0 }; + + [[nodiscard]] bool operator==(const PointerMoved& other) const = default; + }; + + struct PointerLeft { + [[nodiscard]] bool operator==(const PointerLeft& other) const = default; + }; + + struct PointerButtonChanged { + PointerButton button = PointerButton::Left; + bool pressed = false; + + [[nodiscard]] bool operator==(const PointerButtonChanged& other) const = default; + }; + + struct Scrolled { + Vec2F delta = { 0, 0 }; + ScrollSource source = ScrollSource::Wheel; + + [[nodiscard]] bool operator==(const Scrolled& other) const = default; + }; + + struct ScrollEnded { + [[nodiscard]] bool operator==(const ScrollEnded& other) const = default; + }; + + struct KeyChanged { + Key key = Key::Space; + KeyAction action = KeyAction::Press; + + [[nodiscard]] bool operator==(const KeyChanged& other) const = default; + }; + + struct TextEntered { + std::string utf8; + + [[nodiscard]] bool operator==(const TextEntered& other) const = default; + }; + + struct KeyboardFocusChanged { + bool focused = false; + + [[nodiscard]] bool operator==(const KeyboardFocusChanged& other) const = default; + }; + + struct GestureChanged { + TouchGesture gesture; + + [[nodiscard]] bool operator==(const GestureChanged& other) const = default; + }; + + // The one declaration of every input event; sinks, the coalescer, input state and recordings derive from it. + using InputEvent = std::variant< + PointerSampled, + PointerMoved, + PointerLeft, + PointerButtonChanged, + Scrolled, + ScrollEnded, + KeyChanged, + TextEntered, + KeyboardFocusChanged, + GestureChanged>; + + template + struct InputEventVisitor : Handlers... { + using Handlers::operator()...; + }; +} diff --git a/GraphicApplication/src/input_layer/public/InputRecording.hpp b/GraphicApplication/src/input_layer/public/InputRecording.hpp new file mode 100644 index 00000000..15aacd5b --- /dev/null +++ b/GraphicApplication/src/input_layer/public/InputRecording.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "InputSink.hpp" + +#include +#include +#include +#include + +namespace tp { + + struct RecordedInput { + double timeMs = 0; + InputEvent event; + + [[nodiscard]] bool operator==(const RecordedInput& other) const = default; + }; + + struct InputRecording { + static constexpr const char* kHeader = "tp-input-recording 2"; + + Vec2F windowSize = { 0, 0 }; + std::vector events; + + [[nodiscard]] double durationMs() const { return events.empty() ? 0.0 : events.back().timeMs; } + [[nodiscard]] std::string serialize() const; + [[nodiscard]] static std::optional parse(const std::string& text); + + bool save(const std::string& path) const; + [[nodiscard]] static std::optional load(const std::string& path); + + void replayInto(InputSink& sink) const; + [[nodiscard]] InputRecording translated(Vec2F offset) const; + [[nodiscard]] InputRecording slicedToPointerActivity() const; + }; + + class InputRecorder final : public InputSink { + public: + using Clock = std::function; + + explicit InputRecorder(Clock clock = {}); + + void setWindowSize(Vec2F size) { mRecording.windowSize = size; } + void startClockNow() { mStartMs = mClock(); } + [[nodiscard]] const InputRecording& recording() const { return mRecording; } + + void post(const InputEvent& event) override; + + private: + Clock mClock; + double mStartMs = -1; + InputRecording mRecording; + }; +} diff --git a/GraphicApplication/src/input_layer/public/InputSink.hpp b/GraphicApplication/src/input_layer/public/InputSink.hpp new file mode 100644 index 00000000..23f04b3d --- /dev/null +++ b/GraphicApplication/src/input_layer/public/InputSink.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "InputEvent.hpp" + +namespace tp { + + class InputSink { + public: + virtual ~InputSink() = default; + + virtual void post(const InputEvent& event) = 0; + }; + + class DiscardingInputSink final : public InputSink { + public: + void post(const InputEvent&) override {} + }; +} diff --git a/GraphicApplication/src/input_layer/public/InputState.hpp b/GraphicApplication/src/input_layer/public/InputState.hpp new file mode 100644 index 00000000..e58f6310 --- /dev/null +++ b/GraphicApplication/src/input_layer/public/InputState.hpp @@ -0,0 +1,118 @@ +#pragma once + +#include "InputSink.hpp" + +#include +#include +#include +#include + +namespace tp { + + struct ButtonState { + bool pressed = false; + bool down = false; + bool released = false; + }; + + // The input of one frame: the ordered event log plus the per-frame state derived from it. Read-only for consumers. + class InputState { + public: + [[nodiscard]] const std::vector& events() const { return mFrameEvents; } + + [[nodiscard]] Vec2F pointer() const { return mPointer; } + [[nodiscard]] Vec2F previousPointer() const { return mPreviousPointer; } + [[nodiscard]] Vec2F pointerDelta() const { return mPointer - mPreviousPointer; } + [[nodiscard]] bool isPointerInside() const { return mPointerInside; } + [[nodiscard]] const PointerSample& pointerSample() const { return mPointerSample; } + [[nodiscard]] halnf pointerPressure() const; + + [[nodiscard]] const ButtonState& button(PointerButton button) const { return mPointerButtons[(size_t) button]; } + [[nodiscard]] const ButtonState& key(Key key) const { return mKeys[(size_t) key]; } + + [[nodiscard]] Vec2F scroll() const { return mScrollDelta; } + [[nodiscard]] bool isContinuousScroll() const { return mContinuousScroll; } + [[nodiscard]] bool isScrollEnded() const { return mScrollEnded; } + + private: + friend class InputQueue; + + void beginFrame(std::vector events); + void apply(const InputEvent& event); + + std::vector mFrameEvents; + + Vec2F mPointer = { 0, 0 }; + Vec2F mPreviousPointer = { 0, 0 }; + bool mPointerInside = false; + PointerSample mPointerSample; + Vec2F mScrollDelta = { 0, 0 }; + bool mContinuousScroll = false; + bool mScrollEnded = false; + + std::array mPointerButtons{}; + std::array mKeys{}; + }; + + // Collects delivered events between frames and releases held buttons and keys when focus or the pointer is lost. + class InputQueue final : public InputSink { + public: + void post(const InputEvent& event) override; + + [[nodiscard]] size_t pendingEventCount() const { return mPendingEvents.size(); } + void applyPendingEvents(); + + [[nodiscard]] const InputState& state() const { return mState; } + + private: + void releaseHeldPointerButtons(); + void releaseHeldKeys(); + [[nodiscard]] bool tracksHeldInput(const InputEvent& event); + + std::vector mPendingEvents; + std::set mHeldPointerButtons; + std::set mHeldKeys; + InputState mState; + }; + + // A consumer's view of one frame: pointer coordinates relative to an origin, optionally without button/key state. + class InputView { + public: + enum class Buttons { Visible, Hidden }; + + explicit InputView(const InputState& state, Vec2F origin = { 0, 0 }, Buttons buttons = Buttons::Visible) : + mState(state), + mOrigin(origin), + mButtons(buttons) {} + + [[nodiscard]] InputView relativeTo(Vec2F origin) const { return InputView(mState, origin, mButtons); } + [[nodiscard]] InputView withButtons(Buttons buttons) const { return InputView(mState, mOrigin, buttons); } + + [[nodiscard]] const InputState& state() const { return mState; } + + [[nodiscard]] Vec2F getPointer() const { return mState.pointer() - mOrigin; } + [[nodiscard]] Vec2F getPointerPrev() const { return mState.previousPointer() - mOrigin; } + [[nodiscard]] Vec2F getPointerDelta() const { return mState.pointerDelta(); } + [[nodiscard]] halnf getPointerPressure() const { return areButtonsVisible() ? mState.pointerPressure() : 0; } + + [[nodiscard]] bool isPressed(PointerButton button) const { return visible(mState.button(button)).pressed; } + [[nodiscard]] bool isReleased(PointerButton button) const { return visible(mState.button(button)).released; } + [[nodiscard]] bool isDown(PointerButton button) const { return visible(mState.button(button)).down; } + [[nodiscard]] bool isPressed(Key key) const { return visible(mState.key(key)).pressed; } + [[nodiscard]] bool isReleased(Key key) const { return visible(mState.key(key)).released; } + [[nodiscard]] bool isDown(Key key) const { return visible(mState.key(key)).down; } + + [[nodiscard]] halnf getScrollX() const { return mState.scroll().x; } + [[nodiscard]] halnf getScrollY() const { return mState.scroll().y; } + + private: + [[nodiscard]] bool areButtonsVisible() const { return mButtons == Buttons::Visible; } + [[nodiscard]] ButtonState visible(const ButtonState& state) const { + return areButtonsVisible() ? state : ButtonState{}; + } + + const InputState& mState; + Vec2F mOrigin; + Buttons mButtons; + }; +} diff --git a/GraphicApplication/src/input_layer/public/PointerSample.hpp b/GraphicApplication/src/input_layer/public/PointerSample.hpp new file mode 100644 index 00000000..9621f785 --- /dev/null +++ b/GraphicApplication/src/input_layer/public/PointerSample.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "Vec.hpp" + +#include + +namespace tp { + + enum class PointerDevice { Mouse, Finger, Stylus }; + + enum class StylusTip { Pen, Eraser, Brush, Pencil, Airbrush }; + + struct PointerSample { + PointerDevice device = PointerDevice::Mouse; + StylusTip tip = StylusTip::Pen; + halnf pressure = 1; + Vec2F tilt = { 0, 0 }; + halnf rotation = 0; + halnf distance = 0; + uint32_t timeMs = 0; + + [[nodiscard]] bool isStylus() const { return device == PointerDevice::Stylus; } + [[nodiscard]] bool operator==(const PointerSample& other) const = default; + }; +} diff --git a/GraphicApplication/src/input_layer/public/TouchGesture.hpp b/GraphicApplication/src/input_layer/public/TouchGesture.hpp new file mode 100644 index 00000000..4e70325f --- /dev/null +++ b/GraphicApplication/src/input_layer/public/TouchGesture.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include "Vec.hpp" + +#include +#include + +namespace tp { + + enum class GesturePhase { Begin, Update, End }; + + struct TouchGesture { + GesturePhase phase = GesturePhase::Update; + Vec2F center = { 0, 0 }; + Vec2F pan = { 0, 0 }; + halnf scale = 1; + halnf rotation = 0; + int32_t tapFingers = 0; + + [[nodiscard]] bool operator==(const TouchGesture& other) const = default; + }; + + struct TouchPoint { + int32_t id = 0; + Vec2F position = { 0, 0 }; + }; + + class TouchGestureTracker { + public: + static constexpr uint32_t kTapMaximumMs = 300; + static constexpr halnf kTapMaximumTravel = 20; + + void update(const std::vector& touches, std::vector& gestures, uint32_t timeMs = 0); + void cancel(std::vector& gestures); + + [[nodiscard]] bool isActive() const { return mActive; } + + private: + void track(const TouchPoint& first, const TouchPoint& second); + void followTap(const std::vector& touches, uint32_t timeMs); + [[nodiscard]] int32_t finishedTapFingers(uint32_t timeMs) const; + + std::vector mTapStarts; + uint32_t mTapStartMs = 0; + bool mTapStillPossible = false; + + bool mActive = false; + int32_t mFirst = 0; + int32_t mSecond = 0; + Vec2F mCenter = { 0, 0 }; + halnf mDistance = 0; + halnf mAngle = 0; + }; + + void accumulate(TouchGesture& into, const TouchGesture& next); + + [[nodiscard]] halnf wrapAngle(halnf radians); +} diff --git a/GraphicApplication/src/window/private/Window.cpp b/GraphicApplication/src/window/private/Window.cpp new file mode 100644 index 00000000..ba992cf9 --- /dev/null +++ b/GraphicApplication/src/window/private/Window.cpp @@ -0,0 +1,64 @@ +#include "Window.hpp" + +#include "WindowContext.hpp" + +using namespace tp; + +WindowContext::WindowContext(GpuDevice& gpu, Profiler& profiler, const WindowOptions& options) { + platform = createPlatformWindow(*this, options.contentSize, options.identity, options.platform); + surface = std::make_unique(gpu, *platform, profiler); +} + +WindowContext::~WindowContext() { + surface.reset(); + platform.reset(); +} + +InputSink& WindowContext::rawInput() { + if (!platformInputEnabled) { + return mutedInput; + } + return coalescer; +} + +void WindowContext::onSurfaceAvailable() { + if (surface) { + surface->recreateSurface(); + } +} + +void WindowContext::onSurfaceLost() { + if (surface) { + surface->releaseSurface(); + } +} + +std::unique_ptr Window::createGpuDevice() { + return std::make_unique(platformSurfaceExtensions()); +} + +std::filesystem::path Window::platformResourceDirectory() { return tp::platformResourceDirectory(); } + +Window::Window(GpuDevice& gpu, Profiler& profiler, const WindowOptions& options) : + mContext(std::make_unique(gpu, profiler, options)), + mEventLoop(*mContext), + mInput(*mContext), + mControls(*mContext), + mServices(*mContext) {} + +Window::~Window() = default; + +WindowSurface& Window::surface() { return *mContext->surface; } + +Vec2F Window::contentSize() const { return mContext->platform->frame().contentSize(); } + +Vec2F Window::framebufferSize() const { + const VkExtent2D extent = mContext->platform->framebufferExtent(); + return { (halnf) extent.width, (halnf) extent.height }; +} + +halnf Window::contentScale() const { return mContext->platform->scale(); } + +const WindowFrame& Window::frame() const { return mContext->platform->frame(); } + +PlatformInfo Window::platformInfo() const { return mContext->platform->platformInfo(); } diff --git a/GraphicApplication/src/window/private/WindowContext.hpp b/GraphicApplication/src/window/private/WindowContext.hpp new file mode 100644 index 00000000..0e042bab --- /dev/null +++ b/GraphicApplication/src/window/private/WindowContext.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "InputCoalescer.hpp" +#include "InputState.hpp" +#include "PlatformWindow.hpp" +#include "Window.hpp" +#include "WindowSurface.hpp" + +#include + +namespace tp { + + // Everything one window owns; the public roles are views onto it. + class WindowContext final : public PlatformWindowHost { + public: + WindowContext(GpuDevice& gpu, Profiler& profiler, const WindowOptions& options); + ~WindowContext() override; + + WindowContext(const WindowContext&) = delete; + WindowContext& operator=(const WindowContext&) = delete; + + [[nodiscard]] InputSink& rawInput() override; + void onContentResized() override { contentResized = true; } + void onSurfaceAvailable() override; + void onSurfaceLost() override; + + InputCoalescer coalescer; + InputQueue inputQueue; + DiscardingInputSink mutedInput; + bool platformInputEnabled = true; + bool contentResized = false; + + std::unique_ptr platform; + std::unique_ptr surface; + }; +} diff --git a/GraphicApplication/src/window/private/WindowControls.cpp b/GraphicApplication/src/window/private/WindowControls.cpp new file mode 100644 index 00000000..028cfecb --- /dev/null +++ b/GraphicApplication/src/window/private/WindowControls.cpp @@ -0,0 +1,19 @@ +#include "WindowControls.hpp" + +#include "WindowContext.hpp" + +using namespace tp; + +bool WindowControls::shouldClose() const { return mContext.platform->shouldClose(); } + +void WindowControls::requestClose() { mContext.platform->requestClose(); } + +void WindowControls::minimize() { mContext.platform->minimize(); } + +bool WindowControls::isMaximized() const { return mContext.platform->isMaximized(); } + +bool WindowControls::isFullScreen() const { return mContext.platform->isFullScreen(); } + +bool WindowControls::isFocused() const { return mContext.platform->isFocused(); } + +DesktopWindowControls* WindowControls::desktop() { return mContext.platform->desktopControls(); } diff --git a/GraphicApplication/src/window/private/WindowEventLoop.cpp b/GraphicApplication/src/window/private/WindowEventLoop.cpp new file mode 100644 index 00000000..79300d60 --- /dev/null +++ b/GraphicApplication/src/window/private/WindowEventLoop.cpp @@ -0,0 +1,45 @@ +#include "WindowEventLoop.hpp" + +#include "WindowContext.hpp" + +#include + +using namespace tp; + +void WindowEventLoop::processEvents(bool wait) { mContext.platform->dispatchEvents(wait ? -1.0f : 0.0f); } + +void WindowEventLoop::processEventsFor(halnf timeoutMs) { + mContext.platform->dispatchEvents(std::max(timeoutMs, 0.0f)); +} + +void WindowEventLoop::wake() { mContext.platform->wake(); } + +bool WindowEventLoop::isWaitingForFrame() const { return mContext.platform->isWaitingForFrame(); } + +bool WindowEventLoop::hasPendingWork() const { + return mContext.contentResized || !mContext.coalescer.isEmpty() || mContext.platform->hasPendingConfigure(); +} + +uint64_t WindowEventLoop::swapchainRecreations() const { return mContext.surface->swapchainRecreations(); } + +void WindowEventLoop::waitForReady() { + mContext.platform->waitUntilConfigured(); + mContext.surface->syncToSource(); +} + +size_t WindowEventLoop::advanceFrame() { + mContext.platform->applyPendingConfigure(); + mContext.coalescer.deliver(mContext.inputQueue); + + const size_t eventCount = mContext.inputQueue.pendingEventCount(); + mContext.inputQueue.applyPendingEvents(); + mContext.contentResized = false; + return eventCount; +} + +std::optional WindowEventLoop::frameBegin() { return mContext.surface->beginFrame(); } + +void WindowEventLoop::frameEnd() { + mContext.platform->commitFrameGeometry(); + mContext.surface->endFrame(); +} diff --git a/GraphicApplication/src/window/private/WindowInput.cpp b/GraphicApplication/src/window/private/WindowInput.cpp new file mode 100644 index 00000000..cad6d7f3 --- /dev/null +++ b/GraphicApplication/src/window/private/WindowInput.cpp @@ -0,0 +1,27 @@ +#include "WindowInput.hpp" + +#include "WindowContext.hpp" + +using namespace tp; + +const InputState& WindowInput::state() const { return mContext.inputQueue.state(); } + +InputSink& WindowInput::injectInput() { return mContext.coalescer; } + +void WindowInput::setRawInputTap(InputSink* tap) { mContext.coalescer.setTap(tap); } + +void WindowInput::setDeliveryPolicies(const InputDeliveryPolicies& policies) { + mContext.coalescer.setPolicies(policies); +} + +const CoalescingStatistics& WindowInput::statistics() const { return mContext.coalescer.statistics(); } + +void WindowInput::setPlatformInputEnabled(bool enabled) { + if (enabled == mContext.platformInputEnabled) { + return; + } + mContext.platformInputEnabled = enabled; + mContext.platform->setAcceptsInput(enabled); +} + +bool WindowInput::isPlatformInputEnabled() const { return mContext.platformInputEnabled; } diff --git a/GraphicApplication/src/window/private/WindowServices.cpp b/GraphicApplication/src/window/private/WindowServices.cpp new file mode 100644 index 00000000..cf53bc02 --- /dev/null +++ b/GraphicApplication/src/window/private/WindowServices.cpp @@ -0,0 +1,19 @@ +#include "WindowServices.hpp" + +#include "WindowContext.hpp" + +using namespace tp; + +void WindowServices::setCursor(CursorShape shape) { mContext.platform->setCursor(shape); } + +void WindowServices::setClipboardText(const std::string& text) { mContext.platform->setClipboardText(text); } + +std::string WindowServices::clipboardText() { return mContext.platform->clipboardText(); } + +ClipboardImage WindowServices::clipboardImage() { return mContext.platform->clipboardImage(); } + +bool WindowServices::openUrl(const std::string& url) { return mContext.platform->openUrl(url); } + +void WindowServices::setTextInputEnabled(bool enabled) { mContext.platform->setTextInputEnabled(enabled); } + +void WindowServices::setTextInputRect(const RectF& contentRect) { mContext.platform->setTextInputRect(contentRect); } diff --git a/GraphicApplication/src/window/public/Window.hpp b/GraphicApplication/src/window/public/Window.hpp new file mode 100644 index 00000000..e9762663 --- /dev/null +++ b/GraphicApplication/src/window/public/Window.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "GpuDevice.hpp" +#include "InputState.hpp" +#include "PlatformContext.hpp" +#include "PlatformInfo.hpp" +#include "WindowControls.hpp" +#include "WindowEventLoop.hpp" +#include "WindowFrame.hpp" +#include "WindowInput.hpp" +#include "WindowServices.hpp" +#include "WindowSurface.hpp" +#include "WindowTypes.hpp" + +#include +#include + +namespace tp { + + class Profiler; + class WindowContext; + + struct WindowOptions { + Vec2F contentSize = { 1500, 900 }; + WindowIdentity identity; + PlatformContext platform; + }; + + class Window { + public: + // The GPU device with the surface extensions this platform's windows need; share it between windows. + [[nodiscard]] static std::unique_ptr createGpuDevice(); + [[nodiscard]] static std::filesystem::path platformResourceDirectory(); + + Window(GpuDevice& gpu, Profiler& profiler, const WindowOptions& options); + ~Window(); + + Window(const Window&) = delete; + Window& operator=(const Window&) = delete; + + [[nodiscard]] WindowEventLoop& loop() { return mEventLoop; } + [[nodiscard]] WindowInput& input() { return mInput; } + [[nodiscard]] WindowControls& controls() { return mControls; } + [[nodiscard]] WindowServices& services() { return mServices; } + [[nodiscard]] WindowSurface& surface(); + + [[nodiscard]] Vec2F contentSize() const; + [[nodiscard]] Vec2F framebufferSize() const; + [[nodiscard]] halnf contentScale() const; + [[nodiscard]] const WindowFrame& frame() const; + [[nodiscard]] PlatformInfo platformInfo() const; + + private: + std::unique_ptr mContext; + WindowEventLoop mEventLoop; + WindowInput mInput; + WindowControls mControls; + WindowServices mServices; + }; +} diff --git a/GraphicApplication/src/window/public/WindowControls.hpp b/GraphicApplication/src/window/public/WindowControls.hpp new file mode 100644 index 00000000..49ff8797 --- /dev/null +++ b/GraphicApplication/src/window/public/WindowControls.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "DesktopWindowControls.hpp" + +namespace tp { + + class WindowContext; + + class WindowControls { + public: + [[nodiscard]] bool shouldClose() const; + void requestClose(); + void minimize(); + [[nodiscard]] bool isMaximized() const; + [[nodiscard]] bool isFullScreen() const; + [[nodiscard]] bool isFocused() const; + + // Moving, resizing and maximizing; null where the platform has no desktop window manager. + [[nodiscard]] DesktopWindowControls* desktop(); + + private: + friend class Window; + explicit WindowControls(WindowContext& context) : + mContext(context) {} + + WindowContext& mContext; + }; +} diff --git a/GraphicApplication/src/window/public/WindowEventLoop.hpp b/GraphicApplication/src/window/public/WindowEventLoop.hpp new file mode 100644 index 00000000..a2657e01 --- /dev/null +++ b/GraphicApplication/src/window/public/WindowEventLoop.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "Vec.hpp" +#include "WindowSurface.hpp" + +#include +#include +#include + +namespace tp { + + class WindowContext; + + class WindowEventLoop { + public: + void processEvents(bool wait = true); + void processEventsFor(halnf timeoutMs); + void wake(); + [[nodiscard]] bool isWaitingForFrame() const; + [[nodiscard]] bool hasPendingWork() const; + [[nodiscard]] uint64_t swapchainRecreations() const; + void waitForReady(); + + // Applies pending window geometry and moves collected input into input().state(); returns the event count. + size_t advanceFrame(); + [[nodiscard]] std::optional frameBegin(); + void frameEnd(); + + private: + friend class Window; + explicit WindowEventLoop(WindowContext& context) : + mContext(context) {} + + WindowContext& mContext; + }; +} diff --git a/GraphicApplication/src/window/public/WindowInput.hpp b/GraphicApplication/src/window/public/WindowInput.hpp new file mode 100644 index 00000000..a70cb91d --- /dev/null +++ b/GraphicApplication/src/window/public/WindowInput.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "InputCoalescer.hpp" +#include "InputState.hpp" + +namespace tp { + + class WindowContext; + + class WindowInput { + public: + [[nodiscard]] const InputState& state() const; + [[nodiscard]] InputSink& injectInput(); + void setRawInputTap(InputSink* tap); + void setDeliveryPolicies(const InputDeliveryPolicies& policies); + [[nodiscard]] const CoalescingStatistics& statistics() const; + void setPlatformInputEnabled(bool enabled); + [[nodiscard]] bool isPlatformInputEnabled() const; + + private: + friend class Window; + explicit WindowInput(WindowContext& context) : + mContext(context) {} + + WindowContext& mContext; + }; +} diff --git a/GraphicApplication/src/window/public/WindowServices.hpp b/GraphicApplication/src/window/public/WindowServices.hpp new file mode 100644 index 00000000..3e43b017 --- /dev/null +++ b/GraphicApplication/src/window/public/WindowServices.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "Rect.hpp" +#include "WindowTypes.hpp" + +#include + +namespace tp { + class WindowContext; + + class WindowServices { + public: + void setCursor(CursorShape shape); + void setClipboardText(const std::string& text); + [[nodiscard]] std::string clipboardText(); + [[nodiscard]] ClipboardImage clipboardImage(); + bool openUrl(const std::string& url); + void setTextInputEnabled(bool enabled); + void setTextInputRect(const RectF& contentRect); + + private: + friend class Window; + explicit WindowServices(WindowContext& context) : + mContext(context) {} + + WindowContext& mContext; + }; +} diff --git a/GraphicApplication/tests/FontCoverageTests.cpp b/GraphicApplication/tests/FontCoverageTests.cpp new file mode 100644 index 00000000..e74a144e --- /dev/null +++ b/GraphicApplication/tests/FontCoverageTests.cpp @@ -0,0 +1,196 @@ +#include "CanvasFonts.hpp" + +#include "stb_truetype.h" + +#include "UnitTest++/UnitTest++.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + const std::filesystem::path kSourceRoot = MODULES_SOURCE_DIR; + const std::filesystem::path kResourceRoot = kSourceRoot / "GraphicApplication" / "rsc"; + const std::vector kUiSourceDirectories = { + "Widgets/private", "Widgets/public", "Taskboard/private", "Taskboard/public" + }; + + class LoadedFont { + public: + explicit LoadedFont(const tp::FontFile& file) { + std::ifstream stream(kResourceRoot / file.path, std::ios::binary); + mBytes.assign(std::istreambuf_iterator(stream), {}); + const auto* data = reinterpret_cast(mBytes.data()); + mValid = !mBytes.empty() && stbtt_InitFont(&mInfo, data, stbtt_GetFontOffsetForIndex(data, 0)); + } + + [[nodiscard]] bool isValid() const { return mValid; } + [[nodiscard]] bool hasGlyph(char32_t codepoint) const { + return mValid && stbtt_FindGlyphIndex(&mInfo, int(codepoint)) != 0; + } + + private: + std::vector mBytes; + stbtt_fontinfo mInfo{}; + bool mValid = false; + }; + + std::optional decodeUtf8(const std::string& text, size_t& position) { + const auto byte = [&](size_t offset) { return static_cast(text[position + offset]); }; + const unsigned char lead = byte(0); + const size_t length = lead < 0x80 ? 1 : lead >= 0xF0 ? 4 : lead >= 0xE0 ? 3 : lead >= 0xC0 ? 2 : 0; + if (length == 0 || position + length > text.size()) { + position++; + return std::nullopt; + } + + char32_t codepoint = length == 1 ? lead : lead & (0xFF >> (length + 1)); + for (size_t index = 1; index < length; index++) { + codepoint = (codepoint << 6) | (byte(index) & 0x3F); + } + position += length; + return codepoint; + } + + void collectEscapes(const std::string& source, std::set& codepoints) { + static const std::regex escape(R"(\\u([0-9A-Fa-f]{4})|\\U([0-9A-Fa-f]{8}))"); + for (std::sregex_iterator match(source.begin(), source.end(), escape), end; match != end; ++match) { + const std::string digits = (*match)[1].matched ? (*match)[1].str() : (*match)[2].str(); + codepoints.insert(char32_t(std::stoul(digits, nullptr, 16))); + } + } + + void collectLiterals(const std::string& source, std::set& codepoints) { + for (size_t position = 0; position < source.size();) { + const std::optional codepoint = decodeUtf8(source, position); + if (codepoint && *codepoint >= 0x80) { + codepoints.insert(*codepoint); + } + } + } + + std::set codepointsUsedByUi() { + std::set codepoints; + for (const std::string& directory : kUiSourceDirectories) { + if (!std::filesystem::is_directory(kSourceRoot / directory)) { + continue; + } + for (const auto& entry : std::filesystem::recursive_directory_iterator(kSourceRoot / directory)) { + if (!entry.is_regular_file()) { + continue; + } + std::ifstream stream(entry.path(), std::ios::binary); + const std::string source{ std::istreambuf_iterator(stream), {} }; + collectEscapes(source, codepoints); + collectLiterals(source, codepoints); + } + } + return codepoints; + } + + std::string hex(char32_t codepoint) { + char buffer[16]; + std::snprintf(buffer, sizeof(buffer), "U+%04X", unsigned(codepoint)); + return buffer; + } + + struct FaceChain { + const char* label; + tp::FontFile face; + std::vector fallbacks; + }; + + std::vector faceChains() { + return { + { "regular", tp::kRegularFont, { tp::kRegularFallbacks.begin(), tp::kRegularFallbacks.end() } }, + { "bold", tp::kBoldFont, { tp::kBoldFallbacks.begin(), tp::kBoldFallbacks.end() } }, + { "monospace", tp::kMonospaceFont, { tp::kMonospaceFallbacks.begin(), tp::kMonospaceFallbacks.end() } }, + }; + } + + const char* const kRussianAlphabetAndPunctuation = + "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя№«»—–…"; + + std::set codepointsOf(const char* text) { + std::set codepoints; + const std::string source{ text }; + for (size_t position = 0; position < source.size();) { + const std::optional codepoint = decodeUtf8(source, position); + if (codepoint) { + codepoints.insert(*codepoint); + } + } + return codepoints; + } + + void checkChainCovers(const FaceChain& chain, const std::set& codepoints) { + const LoadedFont primary(chain.face); + std::vector fallbacks; + for (const tp::FontFile& file : chain.fallbacks) { + fallbacks.emplace_back(file); + } + + for (const char32_t codepoint : codepoints) { + bool covered = primary.hasGlyph(codepoint); + for (const LoadedFont& fallback : fallbacks) { + covered = covered || fallback.hasGlyph(codepoint); + } + CHECK_EQUAL( + std::string(chain.label) + " " + hex(codepoint) + " drawn", + std::string(chain.label) + " " + hex(codepoint) + (covered ? " drawn" : " has no glyph") + ); + } + } + + template + bool chainContains(const Chain& chain, const tp::FontFile& font) { + for (const tp::FontFile& file : chain) { + if (std::strcmp(file.path, font.path) == 0) { + return true; + } + } + return false; + } +} + +SUITE(FontCoverage) { + TEST(EveryBundledFontLoads) { + for (const tp::FontFile& file : tp::kTextFonts) { + CHECK(LoadedFont(file).isValid()); + } + for (const tp::FontFile& file : tp::kFallbackFonts) { + CHECK(LoadedFont(file).isValid()); + } + } + + TEST(EveryCharacterTheUiDrawsHasAGlyphInEveryTextFace) { + const std::set used = codepointsUsedByUi(); + for (const FaceChain& chain : faceChains()) { + checkChainCovers(chain, used); + } + } + + TEST(RussianTextHasAGlyphInEveryTextFace) { + const std::set russian = codepointsOf(kRussianAlphabetAndPunctuation); + CHECK_EQUAL(size_t(72), russian.size()); + + for (const FaceChain& chain : faceChains()) { + checkChainCovers(chain, russian); + } + } + + TEST(EachFaceFallsBackToTheCyrillicCutOfItsOwnWeight) { + CHECK(chainContains(tp::kRegularFallbacks, tp::kCyrillicRegularFont)); + CHECK(chainContains(tp::kMonospaceFallbacks, tp::kCyrillicRegularFont)); + CHECK(chainContains(tp::kBoldFallbacks, tp::kCyrillicBoldFont)); + CHECK(!chainContains(tp::kBoldFallbacks, tp::kCyrillicRegularFont)); + CHECK(!chainContains(tp::kRegularFallbacks, tp::kCyrillicBoldFont)); + } +} diff --git a/GraphicApplication/tests/HangWatchdogTests.cpp b/GraphicApplication/tests/HangWatchdogTests.cpp new file mode 100644 index 00000000..ff19f66b --- /dev/null +++ b/GraphicApplication/tests/HangWatchdogTests.cpp @@ -0,0 +1,255 @@ +#include "HangWatchdog.hpp" + +#include "UnitTest++/UnitTest++.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace tp; + +namespace { + struct TempDirectory { + std::filesystem::path path; + + explicit TempDirectory(const std::string& name) : + path(std::filesystem::temp_directory_path() / ("hang-watchdog-test-" + name)) { + std::filesystem::remove_all(path); + } + ~TempDirectory() { + if (!std::getenv("KEEP_HANG_REPORTS")) { + std::filesystem::remove_all(path); + } + } + }; + + HangSettings fastSettings(const std::filesystem::path& directory) { + HangSettings settings; + settings.thresholdMs = 150; + settings.followUpMs = {}; + settings.stackSamples = 3; + settings.stackSampleIntervalMs = 30; + settings.application = "test"; + settings.build = "0"; + settings.directory = directory; + return settings; + } + + std::string readFile(const std::filesystem::path& path) { + std::ifstream file(path); + std::stringstream text; + text << file.rdbuf(); + return text.str(); + } + + UiTrail& testTrail() { + static UiTrail trail; + return trail; + } + + struct TestScope { + explicit TestScope(const char* name) { testTrail().push(name); } + ~TestScope() { testTrail().pop(); } + }; + + bool hasVerdict(const std::filesystem::path& report) { + return readFile(report / "summary.txt").find("verdict:") != std::string::npos; + } + + template + bool waitUntil(Done&& done, double limitMs = 5000) { + const auto start = std::chrono::steady_clock::now(); + while (!done()) { + if (std::chrono::duration(std::chrono::steady_clock::now() - start).count() > limitMs) { + return false; + } + std::this_thread::yield(); + } + return true; + } + + class Gate { + public: + void waitOpen() { + std::unique_lock lock(mMutex); + mOpened.wait(lock, [this]() { return mOpen; }); + } + void open() { + { + std::lock_guard lock(mMutex); + mOpen = true; + } + mOpened.notify_all(); + } + + private: + std::mutex mMutex; + std::condition_variable mOpened; + bool mOpen = false; + }; +} + +SUITE(HangWatchdog) { + TEST(StuckDrawWritesAReportNamingTheOpenScopes) { + TempDirectory directory("stuck"); + HangWatchdog watchdog(fastSettings(directory.path), testTrail()); + Gate gate; + watchdog.enterPhase(UiPhase::Draw); + { + TestScope outer("test.outer"); + TestScope waiting("test.waiting_on_gate"); + std::thread release([&]() { + waitUntil([&]() { return watchdog.reportsWritten() > 0 && hasVerdict(watchdog.reports().front()); }); + gate.open(); + }); + gate.waitOpen(); + release.join(); + } + watchdog.enterPhase(UiPhase::Idle); + REQUIRE CHECK_EQUAL(1u, watchdog.reports().size()); + const std::string summary = readFile(watchdog.reports().front() / "summary.txt"); + CHECK(summary.find("drawing") != std::string::npos); + CHECK(summary.find("test.outer") != std::string::npos); + CHECK(summary.find("test.waiting_on_gate") != std::string::npos); + CHECK(summary.find("verdict: blocked") != std::string::npos); + const std::string threads = readFile(watchdog.reports().front() / "threads.txt"); + CHECK(threads.find("UI THREAD") != std::string::npos); + CHECK( + threads.find("waitOpen") != std::string::npos || threads.find("pthread_cond") != std::string::npos || + threads.find("libc") != std::string::npos + ); + CHECK(waitUntil([&]() { + return readFile(watchdog.reports().front() / "summary.txt").find("recovered") != std::string::npos; + })); + } + + TEST(SpinningUiThreadIsReportedAsBusy) { + TempDirectory directory("busy"); + HangWatchdog watchdog(fastSettings(directory.path), testTrail()); + watchdog.enterPhase(UiPhase::Input); + { + TestScope spinning("test.spinning"); + volatile uint64_t work = 0; + while (watchdog.reportsWritten() == 0 || !hasVerdict(watchdog.reports().front())) { + work = work + 1; + } + } + watchdog.enterPhase(UiPhase::Idle); + const std::string summary = readFile(watchdog.reports().front() / "summary.txt"); + CHECK(summary.find("test.spinning") != std::string::npos); + CHECK(summary.find("verdict: busy") != std::string::npos); + } + + TEST(AProcessPauseIsNotCountedAsAHang) { + TempDirectory directory("paused"); + std::atomic clock{ 0 }; + HangSettings settings = fastSettings(directory.path); + settings.thresholdMs = 2000; + settings.processPauseMs = 1000; + settings.clockNs = [&clock]() { return clock.load(); }; + HangWatchdog watchdog(settings, testTrail()); + watchdog.enterPhase(UiPhase::Draw); + + const auto step = [&](int64_t milliseconds) { + const uint64_t seen = watchdog.inspections(); + clock.fetch_add(milliseconds * 1000000); + REQUIRE CHECK(waitUntil([&]() { return watchdog.inspections() > seen; })); + }; + step(100); + step(30000); + step(100); + CHECK_EQUAL(0u, watchdog.reportsWritten()); + + for (int tick = 0; tick < 25; tick++) { + step(100); + } + CHECK_EQUAL(1u, watchdog.reportsWritten()); + watchdog.enterPhase(UiPhase::Idle); + } + + TEST(SummaryIsWrittenBeforeThreadsAreSampled) { + TempDirectory directory("summary-first"); + HangSettings settings = fastSettings(directory.path); + std::filesystem::path seenReport; + bool summaryExisted = false; + HangWatchdog watchdog(settings, testTrail()); + watchdog.addReportFileSource([&]() { + seenReport = watchdog.reports().empty() ? std::filesystem::path() : watchdog.reports().front(); + summaryExisted = !seenReport.empty() && std::filesystem::exists(seenReport / "summary.txt"); + return std::vector{}; + }); + watchdog.enterPhase(UiPhase::Present); + REQUIRE CHECK(waitUntil([&]() { return watchdog.reportsWritten() > 0; })); + watchdog.enterPhase(UiPhase::Idle); + CHECK(summaryExisted); + const std::string summary = readFile(seenReport / "summary.txt"); + CHECK(summary.find("debugger attached no") != std::string::npos); + } + + TEST(InputEventsHandledInOnePhaseAreCounted) { + TempDirectory directory("input-loop"); + HangWatchdog watchdog(fastSettings(directory.path), testTrail()); + watchdog.enterPhase(UiPhase::Input); + { + TestScope inputLoop("test.input_loop"); + while (watchdog.reportsWritten() == 0 || !hasVerdict(watchdog.reports().front())) { + watchdog.noteInputEvents(); + } + } + watchdog.enterPhase(UiPhase::Idle); + const std::string summary = readFile(watchdog.reports().front() / "summary.txt"); + CHECK(summary.find("input events handled during this phase 0") == std::string::npos); + CHECK(summary.find("input events were handled without leaving the input phase") != std::string::npos); + } + + TEST(IdleNeverProducesAReport) { + TempDirectory directory("idle"); + HangWatchdog watchdog(fastSettings(directory.path), testTrail()); + watchdog.enterPhase(UiPhase::Idle); + Gate gate; + std::thread release([&]() { + const auto start = std::chrono::steady_clock::now(); + waitUntil([&]() { return std::chrono::steady_clock::now() - start > std::chrono::milliseconds(600); }); + gate.open(); + }); + gate.waitOpen(); + release.join(); + CHECK_EQUAL(0u, watchdog.reportsWritten()); + } + + TEST(ReportFileSourcesAddTheirOwnFiles) { + TempDirectory directory("reportFileSources"); + HangWatchdog watchdog(fastSettings(directory.path), testTrail()); + watchdog.addReportFileSource([]() { return std::vector{ { "engine.txt", "queue 3\n" } }; }); + watchdog.enterPhase(UiPhase::Input); + REQUIRE CHECK(waitUntil([&]() { return watchdog.reportsWritten() > 0; })); + watchdog.enterPhase(UiPhase::Idle); + CHECK_EQUAL(std::string("queue 3\n"), readFile(watchdog.reports().front() / "engine.txt")); + } + + TEST(OnlyTheNewestReportsAreKept) { + TempDirectory directory("retention"); + std::filesystem::create_directories(directory.path); + for (int index = 0; index < 5; index++) { + std::filesystem::create_directories(directory.path / ("old-" + std::to_string(index))); + } + HangSettings settings = fastSettings(directory.path); + settings.keptReports = 2; + HangWatchdog watchdog(settings, testTrail()); + CHECK_EQUAL(2u, watchdog.reports().size()); + } + + TEST(DirectoryIsTheApplicationsHangFolderUnlessOverridden) { + const std::filesystem::path application = "/home/someone/sketch2d/hangs"; + CHECK(HangWatchdog::directoryFor(application) == application); + setenv("MODULES_HANG_DIR", "/tmp/custom-hangs", 1); + CHECK(HangWatchdog::directoryFor(application) == std::filesystem::path("/tmp/custom-hangs")); + unsetenv("MODULES_HANG_DIR"); + } +} diff --git a/GraphicApplication/tests/InputCoalescerTests.cpp b/GraphicApplication/tests/InputCoalescerTests.cpp new file mode 100644 index 00000000..1090fdd6 --- /dev/null +++ b/GraphicApplication/tests/InputCoalescerTests.cpp @@ -0,0 +1,265 @@ +#include "InputCoalescer.hpp" + +#include "UnitTest++/UnitTest++.h" + +#include +#include +#include + +using namespace tp; + +namespace { + std::string point(Vec2F value) { return std::to_string((int) value.x) + "," + std::to_string((int) value.y); } + + std::string describe(const InputEvent& event) { + return std::visit( + InputEventVisitor{ + [](const PointerSampled& sampled) { + return "sample " + std::to_string((int) std::lround(sampled.sample.pressure * 100)); + }, + [](const PointerMoved& moved) { return "move " + point(moved.position); }, + [](const PointerLeft&) { return std::string("leave"); }, + [](const PointerButtonChanged& changed) { + return std::string(changed.pressed ? "press " : "release ") + std::to_string((int) changed.button); + }, + [](const Scrolled& scrolled) { + return std::string(scrolled.source == ScrollSource::Touchpad ? "touchpad " : "scroll ") + + point(scrolled.delta); + }, + [](const ScrollEnded&) { return std::string("scroll end"); }, + [](const KeyChanged& changed) { + const char* actions[] = { "key down ", "key up ", "repeat " }; + return actions[(int) changed.action] + std::to_string((int) changed.key); + }, + [](const TextEntered& text) { return "text " + text.utf8; }, + [](const KeyboardFocusChanged& focus) { return std::string(focus.focused ? "focus" : "blur"); }, + [](const GestureChanged&) { return std::string("gesture"); }, + }, + event + ); + } + + class LoggingSink : public InputSink { + public: + void post(const InputEvent& event) override { log.push_back(describe(event)); } + + std::vector log; + }; + + std::vector drain(InputCoalescer& coalescer) { + LoggingSink sink; + coalescer.deliver(sink); + return sink.log; + } + + void move(InputCoalescer& coalescer, Vec2F position) { coalescer.post(PointerMoved{ position }); } + + void scroll(InputCoalescer& coalescer, Vec2F delta, ScrollSource source = ScrollSource::Wheel) { + coalescer.post(Scrolled{ delta, source }); + } + + void button(InputCoalescer& coalescer, bool pressed) { + coalescer.post(PointerButtonChanged{ PointerButton::Left, pressed }); + } +} + +SUITE(InputCoalescerPolicies) { + TEST(OppositeScrollsThatCancelDeliverNothing) { + InputCoalescer coalescer; + scroll(coalescer, { 0, 1 }); + scroll(coalescer, { 0, -1 }); + CHECK(drain(coalescer).empty()); + } + + TEST(BurstOfMotionCollapsesToLatestPosition) { + InputCoalescer coalescer; + move(coalescer, { 1, 1 }); + move(coalescer, { 2, 2 }); + move(coalescer, { 3, 3 }); + + const std::vector log = drain(coalescer); + REQUIRE CHECK_EQUAL(1u, log.size()); + CHECK_EQUAL("move 3,3", log[0]); + CHECK_EQUAL(3u, coalescer.statistics().received); + CHECK_EQUAL(1u, coalescer.statistics().delivered); + } + + TEST(EveryPolicyKeepsEachMotionSampleForDrawingApps) { + InputCoalescer coalescer; + coalescer.setPolicies({ DeliveryPolicy::Every, DeliveryPolicy::Accumulate }); + move(coalescer, { 1, 1 }); + move(coalescer, { 2, 2 }); + move(coalescer, { 3, 3 }); + CHECK_EQUAL(3u, drain(coalescer).size()); + } + + TEST(MotionNeverMergesAcrossButtons) { + InputCoalescer coalescer; + move(coalescer, { 1, 1 }); + move(coalescer, { 5, 5 }); + button(coalescer, true); + move(coalescer, { 6, 6 }); + move(coalescer, { 9, 9 }); + button(coalescer, false); + + const std::vector log = drain(coalescer); + REQUIRE CHECK_EQUAL(4u, log.size()); + CHECK_EQUAL("move 5,5", log[0]); + CHECK_EQUAL("press 0", log[1]); + CHECK_EQUAL("move 9,9", log[2]); + CHECK_EQUAL("release 0", log[3]); + } + + TEST(ScrollDeltasAddUpAndDoNotCrossKeys) { + InputCoalescer coalescer; + scroll(coalescer, { 0, 1 }); + scroll(coalescer, { 0, 2 }); + coalescer.post(KeyChanged{ Key::LeftControl, KeyAction::Press }); + scroll(coalescer, { 0, -1 }); + + const std::vector log = drain(coalescer); + REQUIRE CHECK_EQUAL(3u, log.size()); + CHECK_EQUAL("scroll 0,3", log[0]); + CHECK_EQUAL("key down " + std::to_string((int) Key::LeftControl), log[1]); + CHECK_EQUAL("scroll 0,-1", log[2]); + } + + TEST(TouchpadScrollsKeepTheirSourceAndEnd) { + InputCoalescer coalescer; + scroll(coalescer, { 0, 1 }, ScrollSource::Touchpad); + scroll(coalescer, { 0, 2 }, ScrollSource::Touchpad); + coalescer.post(ScrollEnded{}); + + const std::vector log = drain(coalescer); + REQUIRE CHECK_EQUAL(2u, log.size()); + CHECK_EQUAL("touchpad 0,3", log[0]); + CHECK_EQUAL("scroll end", log[1]); + } + + TEST(WheelAndTouchpadScrollsAreNotMergedTogether) { + InputCoalescer coalescer; + scroll(coalescer, { 0, 1 }, ScrollSource::Wheel); + scroll(coalescer, { 0, 2 }, ScrollSource::Touchpad); + + const std::vector log = drain(coalescer); + REQUIRE CHECK_EQUAL(2u, log.size()); + CHECK_EQUAL("scroll 0,1", log[0]); + CHECK_EQUAL("touchpad 0,2", log[1]); + } + + TEST(ScrollsOnEitherSideOfAnEndAreNotMerged) { + InputCoalescer coalescer; + scroll(coalescer, { 0, 1 }, ScrollSource::Touchpad); + coalescer.post(ScrollEnded{}); + scroll(coalescer, { 0, 1 }, ScrollSource::Touchpad); + CHECK_EQUAL(3u, drain(coalescer).size()); + } + + TEST(DiscreteEventsAreNeverMerged) { + InputCoalescer coalescer; + coalescer.post(KeyChanged{ Key::A, KeyAction::Press }); + coalescer.post(KeyChanged{ Key::A, KeyAction::Release }); + coalescer.post(KeyChanged{ Key::A, KeyAction::Press }); + coalescer.post(KeyChanged{ Key::A, KeyAction::Repeat }); + coalescer.post(KeyChanged{ Key::A, KeyAction::Repeat }); + coalescer.post(TextEntered{ "a" }); + coalescer.post(TextEntered{ "b" }); + coalescer.post(KeyboardFocusChanged{ false }); + coalescer.post(PointerLeft{}); + CHECK_EQUAL(9u, drain(coalescer).size()); + } + + TEST(TapSeesEveryRawEventBeforeCoalescing) { + InputCoalescer coalescer; + LoggingSink tap; + coalescer.setTap(&tap); + move(coalescer, { 1, 1 }); + move(coalescer, { 2, 2 }); + coalescer.post(TextEntered{ "q" }); + + CHECK_EQUAL(3u, tap.log.size()); + CHECK_EQUAL(2u, drain(coalescer).size()); + } + + TEST(SuccessiveGestureUpdatesAccumulate) { + InputCoalescer coalescer; + TouchGesture update; + update.scale = 2; + update.pan = { 1, 0 }; + coalescer.post(GestureChanged{ update }); + coalescer.post(GestureChanged{ update }); + + std::vector delivered; + struct Collect : InputSink { + std::vector& events; + explicit Collect(std::vector& events) : + events(events) {} + void post(const InputEvent& event) override { events.push_back(event); } + } collect(delivered); + coalescer.deliver(collect); + + REQUIRE CHECK_EQUAL(1u, delivered.size()); + const TouchGesture& merged = std::get(delivered[0]).gesture; + CHECK_CLOSE(4.0f, merged.scale, 0.0001f); + CHECK_CLOSE(2.0f, merged.pan.x, 0.0001f); + } + + TEST(DeliveryEmptiesTheQueueAndKeepsOrder) { + InputCoalescer coalescer; + coalescer.post(TextEntered{ "x" }); + move(coalescer, { 4, 4 }); + const std::vector log = drain(coalescer); + REQUIRE CHECK_EQUAL(2u, log.size()); + CHECK_EQUAL("text x", log[0]); + CHECK_EQUAL("move 4,4", log[1]); + CHECK(coalescer.isEmpty()); + CHECK_EQUAL(0u, drain(coalescer).size()); + } +} + +SUITE(InputCoalescerPenSamples) { + PointerSample pen(halnf pressure) { return PointerSample{ PointerDevice::Stylus, StylusTip::Pen, pressure }; } + + TEST(EachDeliveredMotionCarriesThePressureItWasRecordedWith) { + InputCoalescer coalescer; + coalescer.setPolicies({ DeliveryPolicy::Every, DeliveryPolicy::Accumulate }); + coalescer.post(PointerSampled{ pen(0.25f) }); + move(coalescer, { 1, 1 }); + coalescer.post(PointerSampled{ pen(0.75f) }); + move(coalescer, { 2, 2 }); + + const std::vector log = drain(coalescer); + REQUIRE CHECK_EQUAL(4u, log.size()); + CHECK_EQUAL("sample 25", log[0]); + CHECK_EQUAL("move 1,1", log[1]); + CHECK_EQUAL("sample 75", log[2]); + CHECK_EQUAL("move 2,2", log[3]); + } + + TEST(LatestMotionKeepsTheNewestSample) { + InputCoalescer coalescer; + coalescer.post(PointerSampled{ pen(0.2f) }); + move(coalescer, { 1, 1 }); + coalescer.post(PointerSampled{ pen(0.9f) }); + move(coalescer, { 4, 4 }); + + const std::vector log = drain(coalescer); + REQUIRE CHECK_EQUAL(2u, log.size()); + CHECK_EQUAL("sample 90", log[0]); + CHECK_EQUAL("move 4,4", log[1]); + } + + TEST(UnchangedSampleIsNotRepeated) { + InputCoalescer coalescer; + coalescer.setPolicies({ DeliveryPolicy::Every, DeliveryPolicy::Accumulate }); + coalescer.post(PointerSampled{ pen(0.5f) }); + move(coalescer, { 1, 1 }); + button(coalescer, true); + move(coalescer, { 2, 2 }); + + const std::vector log = drain(coalescer); + REQUIRE CHECK_EQUAL(4u, log.size()); + CHECK_EQUAL("sample 50", log[0]); + CHECK_EQUAL("move 2,2", log[3]); + } +} diff --git a/GraphicApplication/tests/InputRecordingTests.cpp b/GraphicApplication/tests/InputRecordingTests.cpp new file mode 100644 index 00000000..491114fb --- /dev/null +++ b/GraphicApplication/tests/InputRecordingTests.cpp @@ -0,0 +1,135 @@ +#include "InputRecording.hpp" + +#include "UnitTest++/UnitTest++.h" + +#include + +using namespace tp; + +namespace { + struct ManualClock { + double now = 0; + InputRecorder::Clock clock() { + return [this]() { return now; }; + } + }; + + struct Collector : InputSink { + std::vector events; + void post(const InputEvent& event) override { events.push_back(event); } + }; + + InputRecording penStroke() { + ManualClock time; + InputRecorder recorder(time.clock()); + recorder.setWindowSize({ 1600, 1000 }); + PointerSample pen; + pen.device = PointerDevice::Stylus; + pen.pressure = 0.25f; + pen.tilt = { 0.1f, -0.2f }; + pen.timeMs = 77; + + time.now = 1000; + recorder.post(KeyChanged{ Key::LeftShift, KeyAction::Press }); + time.now = 1010; + recorder.post(PointerSampled{ pen }); + recorder.post(PointerMoved{ { 10, 20 } }); + recorder.post(PointerButtonChanged{ PointerButton::Left, true }); + time.now = 1012.5; + recorder.post(PointerMoved{ { 30, 40 } }); + time.now = 1030; + recorder.post(PointerButtonChanged{ PointerButton::Left, false }); + time.now = 1200; + recorder.post(KeyChanged{ Key::LeftShift, KeyAction::Release }); + return recorder.recording(); + } + + InputRecording roundTripped(const InputRecording& recording) { + const std::optional parsed = InputRecording::parse(recording.serialize()); + return parsed ? *parsed : InputRecording{}; + } +} + +SUITE(InputRecording) { + TEST(RecorderTimesEventsFromTheFirstOne) { + const InputRecording recording = penStroke(); + REQUIRE CHECK_EQUAL(7u, recording.events.size()); + CHECK_EQUAL(0.0, recording.events[0].timeMs); + CHECK_EQUAL(12.5, recording.events[4].timeMs); + CHECK_EQUAL(200.0, recording.durationMs()); + } + + TEST(ExplicitClockStartTimesTheFirstEventFromThere) { + ManualClock time; + InputRecorder recorder(time.clock()); + time.now = 500; + recorder.startClockNow(); + time.now = 740; + recorder.post(PointerMoved{ { 1, 1 } }); + CHECK_EQUAL(240.0, recorder.recording().events.front().timeMs); + } + + TEST(SerializedRecordingParsesBackIdentically) { + const InputRecording recording = penStroke(); + const InputRecording parsed = roundTripped(recording); + CHECK_EQUAL(1600.0f, parsed.windowSize.x); + CHECK(recording.events == parsed.events); + } + + TEST(EveryEventKindRoundTrips) { + ManualClock time; + InputRecorder recorder(time.clock()); + TouchGesture pinch; + pinch.phase = GesturePhase::Update; + pinch.center = { 100, 50 }; + pinch.pan = { 2, -1 }; + pinch.scale = 1.25f; + pinch.rotation = 0.1f; + pinch.tapFingers = 2; + + recorder.post(Scrolled{ { 0, 2 }, ScrollSource::Wheel }); + recorder.post(Scrolled{ { 0, -3 }, ScrollSource::Touchpad }); + recorder.post(ScrollEnded{}); + recorder.post(KeyChanged{ Key::A, KeyAction::Repeat }); + recorder.post(TextEntered{ "a b%\xd0\x96" }); + recorder.post(GestureChanged{ pinch }); + recorder.post(KeyboardFocusChanged{ true }); + recorder.post(PointerLeft{}); + recorder.post(PointerButtonChanged{ PointerButton::Forward, true }); + + CHECK(roundTripped(recorder.recording()).events == recorder.recording().events); + } + + TEST(ParseRejectsForeignText) { + CHECK(!InputRecording::parse("hello\n").has_value()); + CHECK(!InputRecording::parse(std::string(InputRecording::kHeader) + "\n1.0 teleport 3\n").has_value()); + CHECK(!InputRecording::parse(std::string(InputRecording::kHeader) + "\n1.0 button sideways 1\n").has_value()); + } + + TEST(ReplayPostsEventsInOrder) { + const InputRecording recording = penStroke(); + Collector collector; + recording.replayInto(collector); + + REQUIRE CHECK_EQUAL(recording.events.size(), collector.events.size()); + for (size_t index = 0; index < recording.events.size(); index++) { + CHECK(recording.events[index].event == collector.events[index]); + } + } + + TEST(SliceKeepsThePointerActivityAndRebasesTime) { + const InputRecording sliced = penStroke().slicedToPointerActivity(); + REQUIRE CHECK_EQUAL(5u, sliced.events.size()); + CHECK(std::holds_alternative(sliced.events.front().event)); + CHECK_EQUAL(0.0, sliced.events.front().timeMs); + CHECK(std::holds_alternative(sliced.events.back().event)); + CHECK_EQUAL(20.0, sliced.events.back().timeMs); + } + + TEST(TranslateMovesOnlyPositions) { + const InputRecording moved = penStroke().translated({ 5, -5 }); + CHECK_EQUAL(15.0f, std::get(moved.events[2].event).position.x); + CHECK_EQUAL(15.0f, std::get(moved.events[2].event).position.y); + CHECK_EQUAL(0.25f, std::get(moved.events[1].event).sample.pressure); + } +} diff --git a/GraphicApplication/tests/InputStateTests.cpp b/GraphicApplication/tests/InputStateTests.cpp new file mode 100644 index 00000000..8868a05c --- /dev/null +++ b/GraphicApplication/tests/InputStateTests.cpp @@ -0,0 +1,128 @@ +#include "InputState.hpp" + +#include "UnitTest++/UnitTest++.h" + +using namespace tp; + +SUITE(InputState) { + TEST(AllInputKindsArriveInOneOrderedBatch) { + InputQueue queue; + TouchGesture gesture; + gesture.scale = 1.5f; + + queue.post(TextEntered{ "a" }); + queue.post(KeyChanged{ Key::A, KeyAction::Repeat }); + queue.post(GestureChanged{ gesture }); + queue.post(KeyboardFocusChanged{ false }); + + CHECK_EQUAL(4u, queue.pendingEventCount()); + queue.applyPendingEvents(); + CHECK_EQUAL(0u, queue.pendingEventCount()); + + const std::vector& events = queue.state().events(); + REQUIRE CHECK_EQUAL(4u, events.size()); + CHECK(std::get(events[0]).utf8 == "a"); + CHECK(std::get(events[1]) == (KeyChanged{ Key::A, KeyAction::Repeat })); + CHECK_CLOSE(1.5f, std::get(events[2]).gesture.scale, 0.0001f); + CHECK(!std::get(events[3]).focused); + } + + TEST(PressAndReleaseInOneBatchRemainVisible) { + InputQueue queue; + queue.post(PointerButtonChanged{ PointerButton::Left, true }); + queue.post(PointerButtonChanged{ PointerButton::Left, false }); + queue.applyPendingEvents(); + + const ButtonState& left = queue.state().button(PointerButton::Left); + CHECK(left.pressed); + CHECK(left.released); + CHECK(!left.down); + + queue.applyPendingEvents(); + CHECK(!queue.state().button(PointerButton::Left).pressed); + CHECK(!queue.state().button(PointerButton::Left).released); + } + + TEST(KeysAndPointerButtonsAreSeparateStates) { + InputQueue queue; + queue.post(KeyChanged{ Key::A, KeyAction::Press }); + queue.applyPendingEvents(); + + CHECK(queue.state().key(Key::A).down); + CHECK(!queue.state().button(PointerButton::Left).down); + } + + TEST(PointerAndScrollDescribeTheWholeBatch) { + InputQueue queue; + queue.post(PointerMoved{ { 10, 20 } }); + queue.post(PointerMoved{ { 15, 25 } }); + queue.post(Scrolled{ { 1, 2 }, ScrollSource::Wheel }); + queue.post(Scrolled{ { 3, 4 }, ScrollSource::Touchpad }); + queue.applyPendingEvents(); + + const InputState& state = queue.state(); + CHECK_CLOSE(15, state.pointer().x, 0.0001f); + CHECK_CLOSE(25, state.pointer().y, 0.0001f); + CHECK_CLOSE(15, state.pointerDelta().x, 0.0001f); + CHECK_CLOSE(4, state.scroll().x, 0.0001f); + CHECK_CLOSE(6, state.scroll().y, 0.0001f); + CHECK(state.isContinuousScroll()); + CHECK(state.isPointerInside()); + + queue.applyPendingEvents(); + CHECK_CLOSE(0, queue.state().pointerDelta().x, 0.0001f); + CHECK_CLOSE(0, queue.state().scroll().y, 0.0001f); + CHECK(!queue.state().isContinuousScroll()); + } + + TEST(PointerLeftIsAnExplicitEventThatReleasesHeldButtons) { + InputQueue queue; + queue.post(PointerMoved{ { 5, 5 } }); + queue.post(PointerButtonChanged{ PointerButton::Right, true }); + queue.applyPendingEvents(); + CHECK(queue.state().button(PointerButton::Right).down); + + queue.post(PointerLeft{}); + queue.applyPendingEvents(); + + CHECK(!queue.state().isPointerInside()); + CHECK(queue.state().button(PointerButton::Right).released); + const std::vector& events = queue.state().events(); + REQUIRE CHECK_EQUAL(2u, events.size()); + CHECK(std::holds_alternative(events[0])); + CHECK(std::holds_alternative(events[1])); + } + + TEST(FocusLossReleasesHeldKeysBeforeTheFocusEvent) { + InputQueue queue; + queue.post(KeyChanged{ Key::A, KeyAction::Press }); + queue.applyPendingEvents(); + CHECK(queue.state().key(Key::A).down); + + queue.post(KeyboardFocusChanged{ false }); + queue.applyPendingEvents(); + + CHECK(!queue.state().key(Key::A).down); + CHECK(queue.state().key(Key::A).released); + const std::vector& events = queue.state().events(); + REQUIRE CHECK_EQUAL(2u, events.size()); + CHECK(std::get(events[0]).action == KeyAction::Release); + CHECK(!std::get(events[1]).focused); + } + + TEST(ViewOffsetsThePointerAndCanHideButtons) { + InputQueue queue; + queue.post(PointerMoved{ { 30, 40 } }); + queue.post(PointerButtonChanged{ PointerButton::Left, true }); + queue.applyPendingEvents(); + + const InputView view(queue.state(), { 10, 10 }); + CHECK_CLOSE(20, view.getPointer().x, 0.0001f); + CHECK(view.isDown(PointerButton::Left)); + + const InputView hidden = view.withButtons(InputView::Buttons::Hidden); + CHECK(!hidden.isDown(PointerButton::Left)); + CHECK_CLOSE(0, hidden.getPointerPressure(), 0.0001f); + CHECK_CLOSE(30, hidden.relativeTo({ 0, 0 }).getPointer().x, 0.0001f); + } +} diff --git a/GraphicApplication/tests/LinuxInputCodesTests.cpp b/GraphicApplication/tests/LinuxInputCodesTests.cpp new file mode 100644 index 00000000..e4ded266 --- /dev/null +++ b/GraphicApplication/tests/LinuxInputCodesTests.cpp @@ -0,0 +1,39 @@ +#include "UnitTest++/UnitTest++.h" + +#include "LinuxInputCodes.hpp" + +#include + +using namespace tp; + +SUITE(LinuxInputCodes) { + TEST(LettersAndDigitsKeepTheirKeys) { + CHECK(keyFromEvdev(KEY_A) == Key::A); + CHECK(keyFromEvdev(KEY_Z) == Key::Z); + CHECK(keyFromEvdev(KEY_0) == Key::Digit0); + CHECK(keyFromEvdev(KEY_9) == Key::Digit9); + } + + TEST(NavigationAndEditingKeysAreMapped) { + CHECK(keyFromEvdev(KEY_ESC) == Key::Escape); + CHECK(keyFromEvdev(KEY_ENTER) == Key::Enter); + CHECK(keyFromEvdev(KEY_BACKSPACE) == Key::Backspace); + CHECK(keyFromEvdev(KEY_LEFT) == Key::Left); + CHECK(keyFromEvdev(KEY_PAGEDOWN) == Key::PageDown); + } + + TEST(ModifiersKeepSides) { + CHECK(keyFromEvdev(KEY_LEFTCTRL) == Key::LeftControl); + CHECK(keyFromEvdev(KEY_RIGHTSHIFT) == Key::RightShift); + CHECK(keyFromEvdev(KEY_LEFTALT) == Key::LeftAlt); + } + + TEST(UnknownKeysAreIgnored) { CHECK(!keyFromEvdev(KEY_MAX).has_value()); } + + TEST(LinuxButtonsMapToPointerButtons) { + CHECK(pointerButtonFromLinux(BTN_LEFT) == PointerButton::Left); + CHECK(pointerButtonFromLinux(BTN_RIGHT) == PointerButton::Right); + CHECK(pointerButtonFromLinux(BTN_MIDDLE) == PointerButton::Middle); + CHECK(!pointerButtonFromLinux(BTN_TOUCH).has_value()); + } +} diff --git a/GraphicApplication/tests/Main.cpp b/GraphicApplication/tests/Main.cpp new file mode 100644 index 00000000..7ae121b1 --- /dev/null +++ b/GraphicApplication/tests/Main.cpp @@ -0,0 +1,3 @@ +#include "UnitTest++/UnitTest++.h" + +int main() { return UnitTest::RunAllTests(); } diff --git a/GraphicApplication/tests/PlatformInfoTests.cpp b/GraphicApplication/tests/PlatformInfoTests.cpp new file mode 100644 index 00000000..c2f2e4dd --- /dev/null +++ b/GraphicApplication/tests/PlatformInfoTests.cpp @@ -0,0 +1,47 @@ +#include "UnitTest++/UnitTest++.h" + +#include "PlatformInfo.hpp" + +using namespace tp; + +SUITE(PlatformInfo) { + TEST(DefaultsDescribeAnUnknownPlatform) { + const PlatformInfo info; + + CHECK(info.system == OperatingSystem::Unknown); + CHECK(info.formFactor == FormFactor::Unknown); + CHECK(!info.isTouchFirst()); + } + + TEST(TabletsAndPhonesAreTouchFirst) { + PlatformInfo info; + + info.formFactor = FormFactor::Tablet; + CHECK(info.isTouchFirst()); + info.formFactor = FormFactor::Phone; + CHECK(info.isTouchFirst()); + info.formFactor = FormFactor::Desktop; + CHECK(!info.isTouchFirst()); + } + + TEST(DescriptionNamesSystemFormFactorAndCapabilities) { + PlatformInfo info; + info.system = OperatingSystem::Android; + info.systemVersion = "16"; + info.formFactor = FormFactor::Tablet; + info.deviceName = "Google Pixel Tablet"; + info.contentScale = 2; + info.screenSize = { 1280, 800 }; + info.hasTouch = true; + info.hasPen = true; + + const std::string text = describe(info); + + CHECK(text.find("Android 16") != std::string::npos); + CHECK(text.find("tablet Google Pixel Tablet") != std::string::npos); + CHECK(text.find("scale 2.00") != std::string::npos); + CHECK(text.find("screen 1280x800") != std::string::npos); + CHECK(text.find("touch pen") != std::string::npos); + CHECK(text.find("mouse") == std::string::npos); + } +} diff --git a/GraphicApplication/tests/PointerPinchConverterTests.cpp b/GraphicApplication/tests/PointerPinchConverterTests.cpp new file mode 100644 index 00000000..645594c1 --- /dev/null +++ b/GraphicApplication/tests/PointerPinchConverterTests.cpp @@ -0,0 +1,57 @@ +#include "UnitTest++/UnitTest++.h" + +#include "PointerPinchConverter.hpp" + +SUITE(PointerPinchConverter) { + TEST(TotalScaleBecomesIncrementalAndMultipliesBack) { + tp::PointerPinchConverter converter; + const tp::TouchGesture begin = converter.begin({ 10, 20 }); + CHECK(begin.phase == tp::GesturePhase::Begin); + CHECK(begin.center == tp::Vec2F(10.0f, 20.0f)); + + tp::halnf product = 1; + for (tp::halnf total : { 1.2f, 1.5f, 1.5f, 0.9f }) { + const std::optional update = converter.update({ 10, 20 }, { 1, 2 }, total, 0); + REQUIRE CHECK(update.has_value()); + CHECK(update->pan == tp::Vec2F(1.0f, 2.0f)); + product *= update->scale; + CHECK_CLOSE(total, product, 0.0001f); + } + + const std::optional end = converter.end({ 10, 20 }); + REQUIRE CHECK(end.has_value()); + CHECK(end->phase == tp::GesturePhase::End); + } + + TEST(RotationIsConvertedToRadians) { + tp::PointerPinchConverter converter; + (void) converter.begin({ 0, 0 }); + const std::optional update = converter.update({ 0, 0 }, { 0, 0 }, 1, 90); + REQUIRE CHECK(update.has_value()); + CHECK_CLOSE(1.5707963f, update->rotation, 0.0001f); + } + + TEST(UpdatesOutsideAGestureAreIgnoredAndNewGestureRestartsScale) { + tp::PointerPinchConverter converter; + CHECK(!converter.update({ 0, 0 }, { 0, 0 }, 2, 0).has_value()); + CHECK(!converter.end({ 0, 0 }).has_value()); + + (void) converter.begin({ 0, 0 }); + (void) converter.update({ 0, 0 }, { 0, 0 }, 2, 0); + (void) converter.end({ 0, 0 }); + (void) converter.begin({ 0, 0 }); + const std::optional fresh = converter.update({ 0, 0 }, { 0, 0 }, 1.25f, 0); + REQUIRE CHECK(fresh.has_value()); + CHECK_CLOSE(1.25f, fresh->scale, 0.0001f); + } + + TEST(ZeroScaleFromCompositorIsTreatedAsNoChange) { + tp::PointerPinchConverter converter; + (void) converter.begin({ 0, 0 }); + const std::optional zero = converter.update({ 0, 0 }, { 0, 0 }, 0, 0); + REQUIRE CHECK(zero.has_value()); + CHECK_CLOSE(1, zero->scale, 0.0001f); + const std::optional next = converter.update({ 0, 0 }, { 0, 0 }, 2, 0); + CHECK_CLOSE(2, next->scale, 0.0001f); + } +} diff --git a/GraphicApplication/tests/ProfilerTests.cpp b/GraphicApplication/tests/ProfilerTests.cpp new file mode 100644 index 00000000..75667094 --- /dev/null +++ b/GraphicApplication/tests/ProfilerTests.cpp @@ -0,0 +1,138 @@ +#include "Profiler.hpp" + +#include "UnitTest++/UnitTest++.h" + +#include + +using namespace tp; + +namespace { + void burnCpu(double milliseconds) { + const double end = Profiler::threadCpuMs() + milliseconds; + volatile double sink = 0; + while (Profiler::threadCpuMs() < end) { + sink = sink + std::sqrt(sink + 1.0); + } + } + + struct ProfilerScope { + Profiler profiler; + ProfilerScope() { profiler.setEnabled(true); } + }; +} + +SUITE(ProfileStatistics) { + TEST(SummaryReportsPercentilesOfUnsortedValues) { + std::vector values; + for (int value = 100; value >= 1; value--) { + values.push_back(value); + } + + const ProfileStatistics statistics = summarize(values); + CHECK_EQUAL(100u, statistics.samples); + CHECK_CLOSE(50.5, statistics.mean, 1e-9); + CHECK_CLOSE(50.5, statistics.median, 1e-9); + CHECK_CLOSE(95.05, statistics.p95, 1e-9); + CHECK_CLOSE(99.01, statistics.p99, 1e-9); + CHECK_EQUAL(1.0, statistics.minimum); + CHECK_EQUAL(100.0, statistics.maximum); + CHECK_EQUAL(5050.0, statistics.total); + } + + TEST(EmptySummaryIsZero) { + const ProfileStatistics statistics = summarize({}); + CHECK_EQUAL(0u, statistics.samples); + CHECK_EQUAL(0.0, statistics.p99); + } +} + +SUITE(Profiler) { + TEST(DisabledProfilerRecordsNothing) { + Profiler profiler; + const ProfileId section = profiler.section("work"); + profiler.beginFrame(); + profiler.addSectionTime(section, 5); + profiler.endFrame(); + CHECK(profiler.frames().empty()); + } + + TEST(FramesAccumulateSectionsCallsAndCounters) { + ProfilerScope scope; + Profiler& profiler = scope.profiler; + const ProfileId dabs = profiler.counter("dabs"); + const ProfileId stamp = profiler.section("stamp"); + + profiler.beginFrame(); + profiler.addSectionTime(stamp, 1.5); + profiler.addSectionTime(stamp, 0.5); + profiler.addCounter(dabs, 3); + profiler.endFrame(); + profiler.beginFrame(); + profiler.addCounter(dabs, 4); + profiler.endFrame(); + + REQUIRE CHECK_EQUAL(2u, profiler.frames().size()); + CHECK_EQUAL(2.0, profiler.frames()[0].sectionMs[stamp]); + CHECK_EQUAL(2u, profiler.frames()[0].sectionCalls[stamp]); + CHECK_EQUAL(0.0, profiler.frames()[1].sectionMs[stamp]); + CHECK_EQUAL(7.0, profiler.counterTotal("dabs")); + CHECK_EQUAL(2u, profiler.sectionCallTotal("stamp")); + CHECK_EQUAL(1u, profiler.frames()[1].index); + } + + TEST(WorkOutsideAFrameIsIgnored) { + ProfilerScope scope; + const ProfileId dabs = scope.profiler.counter("dabs"); + scope.profiler.addCounter(dabs, 9); + scope.profiler.beginFrame(); + scope.profiler.endFrame(); + CHECK_EQUAL(0.0, scope.profiler.counterTotal("dabs")); + } + + TEST(SectionsInternByName) { + Profiler profiler; + const ProfileId first = profiler.section("draw"); + CHECK_EQUAL(first, profiler.section("draw")); + CHECK(first != profiler.section("process")); + } + + TEST(FrameMeasuresWallAndThreadCpuTime) { + ProfilerScope scope; + scope.profiler.beginFrame(); + burnCpu(4); + scope.profiler.endFrame(); + const FrameProfile& frame = scope.profiler.frames().front(); + CHECK(frame.cpuMs >= 4.0); + CHECK(frame.wallMs >= frame.cpuMs * 0.9); + } + + TEST(ScopeTimesItsSectionAndRecordsTheTrail) { + ProfilerScope scope; + Profiler& profiler = scope.profiler; + profiler.trail().trackCallingThread(); + profiler.beginFrame(); + { + const ProfileScope timed(profiler, "timed scope"); + REQUIRE CHECK_EQUAL(1u, profiler.trail().snapshot().size()); + CHECK_EQUAL(std::string("timed scope"), profiler.trail().snapshot()[0]); + burnCpu(2); + } + profiler.endFrame(); + profiler.trail().stopTracking(); + + CHECK(profiler.trail().snapshot().empty()); + CHECK(profiler.sectionTotal("timed scope") >= 2.0); + CHECK_EQUAL(1u, profiler.sectionCallTotal("timed scope")); + } + + TEST(DurationMeasuredElsewhereUsesTheSectionChannel) { + ProfilerScope scope; + const ProfileId gpu = scope.profiler.section("gpu frame"); + scope.profiler.beginFrame(); + scope.profiler.addSectionTime(gpu, 0.75); + scope.profiler.endFrame(); + REQUIRE CHECK_EQUAL(1u, scope.profiler.sectionSeries("gpu frame").size()); + CHECK_EQUAL(0.75, scope.profiler.sectionSeries("gpu frame")[0]); + CHECK_EQUAL(1u, scope.profiler.sectionCallTotal("gpu frame")); + } +} diff --git a/GraphicApplication/tests/RenderTargetRegistryTests.cpp b/GraphicApplication/tests/RenderTargetRegistryTests.cpp new file mode 100644 index 00000000..4ac457c2 --- /dev/null +++ b/GraphicApplication/tests/RenderTargetRegistryTests.cpp @@ -0,0 +1,39 @@ +#include "RenderTargetRegistry.hpp" + +#include "UnitTest++/UnitTest++.h" + +using namespace tp; + +SUITE(RenderTargetRegistry) { + TEST(InstancesKeepIndependentTargets) { + RenderTargetRegistry first; + RenderTargetRegistry second; + RenderTargetEntry firstTarget; + firstTarget.width = 640; + firstTarget.height = 480; + RenderTargetEntry secondTarget; + secondTarget.width = 320; + secondTarget.height = 200; + + const uint32_t firstId = first.registerTarget(firstTarget); + const uint32_t secondId = second.registerTarget(secondTarget); + CHECK_EQUAL(firstId, secondId); + + RenderTargetEntry resolved; + REQUIRE CHECK(first.resolve(firstId, resolved)); + CHECK_EQUAL(640, resolved.width); + REQUIRE CHECK(second.resolve(secondId, resolved)); + CHECK_EQUAL(320, resolved.width); + + firstTarget.width = 800; + first.updateTarget(firstId, firstTarget); + REQUIRE CHECK(first.resolve(firstId, resolved)); + CHECK_EQUAL(800, resolved.width); + REQUIRE CHECK(second.resolve(secondId, resolved)); + CHECK_EQUAL(320, resolved.width); + + first.unregisterTarget(firstId); + CHECK(!first.resolve(firstId, resolved)); + CHECK(second.resolve(secondId, resolved)); + } +} diff --git a/GraphicApplication/tests/TouchGestureTests.cpp b/GraphicApplication/tests/TouchGestureTests.cpp new file mode 100644 index 00000000..770e134e --- /dev/null +++ b/GraphicApplication/tests/TouchGestureTests.cpp @@ -0,0 +1,174 @@ +#include "UnitTest++/UnitTest++.h" + +#include "InputCoalescer.hpp" +#include "TouchGesture.hpp" + +#include +#include + +using namespace tp; + +namespace { + std::vector feed(TouchGestureTracker& tracker, std::vector touches, uint32_t timeMs = 0) { + std::vector gestures; + tracker.update(touches, gestures, timeMs); + return gestures; + } + + int32_t tapFingersOf(const std::vector& gestures) { + return gestures.empty() || gestures.back().phase != GesturePhase::End ? -1 : gestures.back().tapFingers; + } + + class GestureLog : public InputSink { + public: + void post(const InputEvent& event) override { + if (const auto* changed = std::get_if(&event)) { + gestures.push_back(changed->gesture); + } + } + std::vector gestures; + }; +} + +SUITE(TouchGestureTracker) { + TEST(OneFingerProducesNoGesture) { + TouchGestureTracker tracker; + + CHECK(feed(tracker, { { 1, { 10, 10 } } }).empty()); + CHECK(!tracker.isActive()); + } + + TEST(SecondFingerBeginsAtTheMidpoint) { + TouchGestureTracker tracker; + + const auto gestures = feed(tracker, { { 1, { 0, 0 } }, { 2, { 100, 0 } } }); + + REQUIRE CHECK_EQUAL(1u, gestures.size()); + CHECK(gestures[0].phase == GesturePhase::Begin); + CHECK_CLOSE(50.0f, gestures[0].center.x, 0.001f); + CHECK(tracker.isActive()); + } + + TEST(MovingBothFingersPans) { + TouchGestureTracker tracker; + feed(tracker, { { 1, { 0, 0 } }, { 2, { 100, 0 } } }); + + const auto gestures = feed(tracker, { { 1, { 10, 20 } }, { 2, { 110, 20 } } }); + + REQUIRE CHECK_EQUAL(1u, gestures.size()); + CHECK(gestures[0].phase == GesturePhase::Update); + CHECK_CLOSE(10.0f, gestures[0].pan.x, 0.001f); + CHECK_CLOSE(20.0f, gestures[0].pan.y, 0.001f); + CHECK_CLOSE(1.0f, gestures[0].scale, 0.001f); + CHECK_CLOSE(0.0f, gestures[0].rotation, 0.001f); + } + + TEST(SpreadingFingersScalesAroundTheMidpoint) { + TouchGestureTracker tracker; + feed(tracker, { { 1, { 50, 0 } }, { 2, { 150, 0 } } }); + + const auto gestures = feed(tracker, { { 1, { 0, 0 } }, { 2, { 200, 0 } } }); + + REQUIRE CHECK_EQUAL(1u, gestures.size()); + CHECK_CLOSE(2.0f, gestures[0].scale, 0.001f); + CHECK_CLOSE(0.0f, gestures[0].pan.x, 0.001f); + CHECK_CLOSE(100.0f, gestures[0].center.x, 0.001f); + } + + TEST(TurningFingersRotates) { + TouchGestureTracker tracker; + feed(tracker, { { 1, { -50, 0 } }, { 2, { 50, 0 } } }); + + const auto gestures = feed(tracker, { { 1, { 0, -50 } }, { 2, { 0, 50 } } }); + + REQUIRE CHECK_EQUAL(1u, gestures.size()); + CHECK_CLOSE(std::numbers::pi_v / 2, gestures[0].rotation, 0.001f); + CHECK_CLOSE(1.0f, gestures[0].scale, 0.001f); + } + + TEST(LiftingAFingerEnds) { + TouchGestureTracker tracker; + feed(tracker, { { 1, { 0, 0 } }, { 2, { 100, 0 } } }); + + const auto gestures = feed(tracker, { { 1, { 0, 0 } } }, 1000); + + REQUIRE CHECK_EQUAL(1u, gestures.size()); + CHECK(gestures[0].phase == GesturePhase::End); + CHECK_EQUAL(0, gestures[0].tapFingers); + CHECK(!tracker.isActive()); + } + + TEST(QuickTwoFingerTouchIsATwoFingerTap) { + TouchGestureTracker tracker; + feed(tracker, { { 1, { 0, 0 } } }, 1000); + feed(tracker, { { 1, { 0, 0 } }, { 2, { 100, 0 } } }, 1020); + CHECK(feed(tracker, { { 2, { 102, 1 } } }, 1120).empty()); + + CHECK_EQUAL(2, tapFingersOf(feed(tracker, {}, 1150))); + } + + TEST(QuickThreeFingerTouchIsAThreeFingerTap) { + TouchGestureTracker tracker; + feed(tracker, { { 1, { 0, 0 } }, { 2, { 100, 0 } } }, 1000); + feed(tracker, { { 1, { 0, 0 } }, { 2, { 100, 0 } }, { 3, { 50, 80 } } }, 1030); + feed(tracker, { { 3, { 50, 80 } } }, 1150); + + CHECK_EQUAL(3, tapFingersOf(feed(tracker, {}, 1200))); + } + + TEST(PinchingIsNotATap) { + TouchGestureTracker tracker; + feed(tracker, { { 1, { 0, 0 } }, { 2, { 100, 0 } } }, 1000); + feed(tracker, { { 1, { 0, 0 } }, { 2, { 160, 0 } } }, 1100); + + CHECK_EQUAL(0, tapFingersOf(feed(tracker, {}, 1200))); + } + + TEST(HoldingTwoFingersIsNotATap) { + TouchGestureTracker tracker; + feed(tracker, { { 1, { 0, 0 } }, { 2, { 100, 0 } } }, 1000); + + CHECK_EQUAL(0, tapFingersOf(feed(tracker, {}, 1000 + TouchGestureTracker::kTapMaximumMs + 50))); + } + + TEST(OneFingerTapIsNotReported) { + TouchGestureTracker tracker; + feed(tracker, { { 1, { 0, 0 } } }, 1000); + + CHECK(feed(tracker, {}, 1050).empty()); + } + + TEST(SwappedFingerRebasesWithoutJumping) { + TouchGestureTracker tracker; + feed(tracker, { { 1, { 0, 0 } }, { 2, { 100, 0 } } }); + + CHECK(feed(tracker, { { 1, { 0, 0 } }, { 3, { 400, 300 } } }).empty()); + const auto gestures = feed(tracker, { { 1, { 0, 0 } }, { 3, { 410, 300 } } }); + + REQUIRE CHECK_EQUAL(1u, gestures.size()); + CHECK_CLOSE(5.0f, gestures[0].pan.x, 0.001f); + } +} + +SUITE(TouchGestureCoalescing) { + TEST(ConsecutiveUpdatesMergeIntoOne) { + InputCoalescer coalescer; + coalescer.post(GestureChanged{ { GesturePhase::Begin, { 50, 0 } } }); + coalescer.post(GestureChanged{ { GesturePhase::Update, { 60, 0 }, { 10, 0 }, 2.0f, 0.1f } }); + coalescer.post(GestureChanged{ { GesturePhase::Update, { 65, 5 }, { 5, 5 }, 1.5f, 0.2f } }); + coalescer.post(GestureChanged{ { GesturePhase::End, { 65, 5 } } }); + + GestureLog log; + coalescer.deliver(log); + + REQUIRE CHECK_EQUAL(3u, log.gestures.size()); + CHECK(log.gestures[0].phase == GesturePhase::Begin); + CHECK(log.gestures[1].phase == GesturePhase::Update); + CHECK_CLOSE(15.0f, log.gestures[1].pan.x, 0.001f); + CHECK_CLOSE(5.0f, log.gestures[1].pan.y, 0.001f); + CHECK_CLOSE(3.0f, log.gestures[1].scale, 0.001f); + CHECK_CLOSE(0.3f, log.gestures[1].rotation, 0.001f); + CHECK_CLOSE(65.0f, log.gestures[1].center.x, 0.001f); + CHECK(log.gestures[2].phase == GesturePhase::End); + } +} diff --git a/GraphicApplication/tests/WindowFrameTests.cpp b/GraphicApplication/tests/WindowFrameTests.cpp new file mode 100644 index 00000000..e2970322 --- /dev/null +++ b/GraphicApplication/tests/WindowFrameTests.cpp @@ -0,0 +1,106 @@ +#include "UnitTest++/UnitTest++.h" + +#include "WindowFrame.hpp" + +using namespace tp; + +namespace { + WindowFrame floatingFrame() { + WindowFrame frame; + frame.setContentSize({ 800, 600 }); + return frame; + } +} + +SUITE(WindowFrameGeometry) { + TEST(FloatingTranslucentFrameReservesShadowMargin) { + const WindowFrame frame = floatingFrame(); + const halnf margin = WindowFrame::kShadowMargin; + + CHECK(frame.hasShadow()); + CHECK_CLOSE(800 + margin * 2, frame.surfaceSize().x, 0.001f); + CHECK_CLOSE(600 + margin * 2, frame.surfaceSize().y, 0.001f); + CHECK_CLOSE(margin, frame.contentRect().x, 0.001f); + CHECK_CLOSE(WindowFrame::kCornerRadius, frame.cornerRadius(), 0.001f); + } + + TEST(TiledFrameDropsShadowAndCorners) { + WindowFrame frame = floatingFrame(); + frame.setTiled(true); + + CHECK(!frame.hasShadow()); + CHECK_CLOSE(0, frame.margin(), 0.001f); + CHECK_CLOSE(0, frame.cornerRadius(), 0.001f); + CHECK_CLOSE(800, frame.surfaceSize().x, 0.001f); + } + + TEST(OpaqueSurfaceDropsShadowButKeepsResizeEdges) { + WindowFrame frame = floatingFrame(); + frame.setTranslucent(false); + + CHECK(!frame.hasShadow()); + CHECK(frame.edgeAt({ 1, 300 }) == WindowEdge::Left); + CHECK(frame.edgeAt({ 400, 300 }) == WindowEdge::None); + } + + TEST(InputRegionCoversContentPlusOuterResizeBorder) { + const WindowFrame frame = floatingFrame(); + const RectF input = frame.inputRect(); + + CHECK_CLOSE(WindowFrame::kShadowMargin - WindowFrame::kResizeBorder, input.x, 0.001f); + CHECK_CLOSE(800 + WindowFrame::kResizeBorder * 2, input.z, 0.001f); + } +} + +SUITE(WindowFrameEdges) { + TEST(ShadowOutsideResizeBorderIsNotInteractive) { + const WindowFrame frame = floatingFrame(); + CHECK(frame.edgeAt({ 1, 1 }) == WindowEdge::None); + } + + TEST(SidesResolveToSingleEdges) { + const WindowFrame frame = floatingFrame(); + const RectF content = frame.contentRect(); + const Vec2F middle = { content.x + content.z / 2, content.y + content.w / 2 }; + + CHECK(frame.edgeAt({ content.x - 2, middle.y }) == WindowEdge::Left); + CHECK(frame.edgeAt({ content.x + content.z + 2, middle.y }) == WindowEdge::Right); + CHECK(frame.edgeAt({ middle.x, content.y - 2 }) == WindowEdge::Top); + CHECK(frame.edgeAt({ middle.x, content.y + content.w + 2 }) == WindowEdge::Bottom); + } + + TEST(InnerResizeStripStaysThin) { + const WindowFrame frame = floatingFrame(); + const RectF content = frame.contentRect(); + + CHECK(frame.edgeAt({ content.x + 1, content.y + 200 }) == WindowEdge::Left); + CHECK(frame.edgeAt({ content.x + 6, content.y + 200 }) == WindowEdge::None); + } + + TEST(CornersWinOverSides) { + const WindowFrame frame = floatingFrame(); + const RectF content = frame.contentRect(); + + CHECK(frame.edgeAt({ content.x - 2, content.y + 4 }) == WindowEdge::TopLeft); + CHECK(frame.edgeAt({ content.x + 4, content.y - 2 }) == WindowEdge::TopLeft); + CHECK(frame.edgeAt({ content.x + content.z + 2, content.y + content.w - 4 }) == WindowEdge::BottomRight); + CHECK(frame.edgeAt({ content.x + content.z - 4, content.y - 2 }) == WindowEdge::TopRight); + CHECK(frame.edgeAt({ content.x - 2, content.y + content.w - 4 }) == WindowEdge::BottomLeft); + } + + TEST(TiledFrameHasNoResizeEdges) { + WindowFrame frame = floatingFrame(); + frame.setTiled(true); + + CHECK(frame.edgeAt({ 0, 0 }) == WindowEdge::None); + CHECK(frame.edgeAt({ 1, 300 }) == WindowEdge::None); + } + + TEST(EdgeCursorsMatchAxes) { + CHECK(cursorForEdge(WindowEdge::Left) == CursorShape::ResizeHorizontal); + CHECK(cursorForEdge(WindowEdge::Bottom) == CursorShape::ResizeVertical); + CHECK(cursorForEdge(WindowEdge::TopLeft) == CursorShape::ResizeNorthWestSouthEast); + CHECK(cursorForEdge(WindowEdge::BottomLeft) == CursorShape::ResizeNorthEastSouthWest); + CHECK(cursorForEdge(WindowEdge::None) == CursorShape::Default); + } +} diff --git a/Graphics/CMakeLists.txt b/Graphics/CMakeLists.txt deleted file mode 100644 index 3d4118bd..00000000 --- a/Graphics/CMakeLists.txt +++ /dev/null @@ -1,23 +0,0 @@ -project(Graphics) - -### ---------------------- Externals --------------------- ### -set(BINDINGS_INCLUDE ../Externals/glfw/include ${GLEW_INCLUDE_DIR}) -set(BINDINGS_LIBS glfw Imgui Nanovg ${GLEW_LIB}) - -### ---------------------- Static Library --------------------- ### -file(GLOB SOURCES "./private/*.cpp") -file(GLOB HEADERS "./public/*.hpp") -add_library(${PROJECT_NAME} STATIC ${SOURCES} ${HEADERS}) -target_include_directories(${PROJECT_NAME} PUBLIC ./public/) -target_include_directories(${PROJECT_NAME} PRIVATE ${BINDINGS_INCLUDE}) -target_link_libraries(${PROJECT_NAME} PUBLIC Math) -target_link_libraries(${PROJECT_NAME} PRIVATE ${BINDINGS_LIBS}) - -### -------------------------- Examples -------------------------- ### -add_executable(Example${PROJECT_NAME} ./examples/Example.cpp) -target_link_libraries(Example${PROJECT_NAME} ${PROJECT_NAME} ${BINDINGS_LIBS}) -target_include_directories(Example${PROJECT_NAME} PRIVATE ${BINDINGS_INCLUDE}) - - -file(COPY "../Graphics/rsc" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/") - diff --git a/Graphics/examples/Example.cpp b/Graphics/examples/Example.cpp deleted file mode 100644 index 39907efb..00000000 --- a/Graphics/examples/Example.cpp +++ /dev/null @@ -1,34 +0,0 @@ - -#include "GraphicApplication.hpp" -#include - - -#include "imgui.h" - -using namespace tp; - -class ExampleApplication : public Application { -public: - ExampleApplication() = default; - - void processFrame(EventHandler* eventHandler, halnf) override { - // example - } - - virtual void drawFrame(Canvas* canvas) override { - glClear(GL_COLOR_BUFFER_BIT); - - ImGui::ShowDemoWindow(); - - drawDebug(); - } - - bool forceNewFrame() override { return false; } - - virtual ~ExampleApplication() = default; -}; - -int main() { - ExampleApplication app; - app.run(); -} diff --git a/Graphics/private/Canvas.cpp b/Graphics/private/Canvas.cpp deleted file mode 100644 index 82a2cc5a..00000000 --- a/Graphics/private/Canvas.cpp +++ /dev/null @@ -1,416 +0,0 @@ -#include "Window.hpp" - -// -------- OpenGL -------- // -#define GLEW_STATIC -#include - -#include "Graphics.hpp" - -// -------- Canvas -------- // -#define NANOVG_GL3_IMPLEMENTATION -#include -#include - -namespace tp { - class Canvas::Context { - public: - NVGcontext* vg {}; - Window* window {}; - }; -} - -using namespace tp; - -Canvas::Canvas(Window* window) { - mContext = new Context(); - mContext->window = window; - - mContext->vg = nvgCreateGL3(NVG_ANTIALIAS | NVG_STENCIL_STROKES); - - if (nvgCreateFont(mContext->vg, "default", "rsc/Font.ttf") == -1) { - ASSERT(!"Cant create NVG font") - } -} - -Canvas::~Canvas() { - nvgDeleteGL3(mContext->vg); - delete mContext; -} - -void Canvas::rect(RectF rec, const RGBA& col, halnf round) { - rec.pos += mOrigin; - - nvgBeginPath(mContext->vg); - - if (round == 0) { - nvgRect(mContext->vg, rec.x, rec.y, rec.z, rec.w); - } else { - nvgRoundedRect(mContext->vg, rec.x, rec.y, rec.z, rec.w, round); - } - - nvgFillColor(mContext->vg, { col.r, col.g, col.b, col.a }); - nvgFill(mContext->vg); -} - -void Canvas::debugCross(RectF rec, const RGBA& col) { - nvgBeginPath(mContext->vg); - - nvgMoveTo(mContext->vg, rec.p1().x, rec.p1().y); - nvgLineTo(mContext->vg, rec.p3().x, rec.p3().y); - - nvgMoveTo(mContext->vg, rec.p2().x, rec.p2().y); - nvgLineTo(mContext->vg, rec.p4().x, rec.p4().y); - - nvgStrokeWidth(mContext->vg, 2); - nvgStrokeColor(mContext->vg, { col.r, col.g, col.b, col.a }); - nvgStroke(mContext->vg); - // nvgFill(mContext->vg); -} - -void Canvas::frame(RectF rec, const RGBA& col, halnf round) { - nvgBeginPath(mContext->vg); - - nvgMoveTo(mContext->vg, rec.p1().x, rec.p1().y); - nvgLineTo(mContext->vg, rec.p2().x, rec.p2().y); - nvgLineTo(mContext->vg, rec.p3().x, rec.p3().y); - nvgLineTo(mContext->vg, rec.p4().x, rec.p4().y); - nvgLineTo(mContext->vg, rec.p1().x, rec.p1().y); - - nvgStrokeWidth(mContext->vg, 2); - nvgStrokeColor(mContext->vg, { col.r, col.g, col.b, col.a }); - nvgStroke(mContext->vg); - // nvgFill(mContext->vg); -} - -void Canvas::line(Vec2F start, Vec2F end, const RGBA& col, halnf thickness) { - start += mOrigin; - end += mOrigin; - - nvgBeginPath(mContext->vg); - nvgFillColor(mContext->vg, { col.r, col.g, col.b, col.a}); - nvgMoveTo(mContext->vg, start.x, start.y); - nvgLineTo(mContext->vg, end.x, end.y); - nvgStrokeWidth(mContext->vg, thickness); - nvgStrokeColor(mContext->vg, { col.r, col.g, col.b, col.a }); - nvgStroke(mContext->vg); -} - -void Canvas::circle(Vec2F pos, halnf size, const RGBA& col) { - pos += mOrigin; - - nvgBeginPath(mContext->vg); - nvgCircle(mContext->vg, pos.x, pos.y, size); - - nvgFillColor(mContext->vg, { col.r, col.g, col.b, col.a }); - nvgFill(mContext->vg); -} - -void Canvas::setOrigin(const Vec2F& origin) { - mOrigin = origin; -} - -void Canvas::pushClamp(const RectF& rec) { - RectF intersection = rec; - if (mScissors.size()) { - mScissors.last().calcIntersection(rec, intersection); - } - nvgScissor(mContext->vg, intersection.x, intersection.y, intersection.z, intersection.w); - mScissors.append(intersection); -} - -void Canvas::popClamp() { - DEBUG_ASSERT(mScissors.size()); - mIsClamping = false; - mScissors.pop(); - if (mScissors.size()) { - const auto& rec = mScissors.last(); - nvgScissor(mContext->vg, rec.x, rec.y, rec.z, rec.w); - } else { - nvgResetScissor(mContext->vg); - } -} - -const RectF& Canvas::getClampedArea() const { - return mScissors.last(); -} - -void Canvas::text( - const char* string, const RectF& aRec, halnf size, Align align, halnf marging, const RGBA& col -) { - - RectF rec = { aRec.x + marging, aRec.y + marging, aRec.z - marging * 2, aRec.w - marging * 2 }; - rec.pos += mOrigin; - - pushClamp(rec); - - nvgFontSize(mContext->vg, size); - nvgFontFace(mContext->vg, "default"); - nvgFillColor(mContext->vg, { col.r, col.g, col.b, col.a }); - - float centerX = rec.x; - float centerY = rec.y; - int alignNVG = 0; - - if (((int1*) &align)[1] == 0x00) { // center x - alignNVG |= NVG_ALIGN_CENTER; - centerX += rec.z * 0.5f; - } else if (((int1*) &align)[1] == 0x01) { // left x - alignNVG |= NVG_ALIGN_LEFT; - } else if (((int1*) &align)[1] == 0x02) { // right x - alignNVG |= NVG_ALIGN_RIGHT; - centerX += rec.z; - } - - if (((int1*) &align)[0] == 0x00) { // center y - alignNVG |= NVG_ALIGN_MIDDLE; - centerY += rec.w * 0.5f; - } else if (((int1*) &align)[0] == 0x01) { // top y - alignNVG |= NVG_ALIGN_TOP; - centerY += rec.w; - } else if (((int1*) &align)[0] == 0x02) { // bottom y - alignNVG |= NVG_ALIGN_BOTTOM; - } - - nvgTextAlign(mContext->vg, alignNVG); - - nvgText(mContext->vg, centerX, centerY, string, nullptr); - - popClamp(); -} - -void Canvas::drawImage(RectF rec, ImageHandle* image, halnf angle, halnf alpha, halnf rounding) { - rec.pos += mOrigin; - - auto imgPaint = nvgImagePattern(mContext->vg, rec.x, rec.y, rec.z, rec.w, angle, image->id, alpha); - nvgBeginPath(mContext->vg); - nvgRoundedRect(mContext->vg, rec.x, rec.y, rec.z, rec.w, rounding); - nvgFillPaint(mContext->vg, imgPaint); - nvgFill(mContext->vg); -} - - Canvas::ImageHandle Canvas::createImageFromTextId(ualni id, Vec2F size) { -#ifdef ENV_OS_ANDROID - return { (ualni) nvglCreateImageFromHandleGLES3(mContext->vg, id, size.x, size.y, 0) }; -#else - //return { (ualni) nvglCreateImageFromHandleGL3(mContext->vg, id, size.x, size.y, 0) }; - return { (ualni) nvglCreateImageFromHandleGL3(mContext->vg, id, size.x, size.y, 0) }; -#endif -} - -void Canvas::updateTextureID(ImageHandle handle, ualni id) { - auto nvgl = (GLNVGcontext*) nvgInternalParams(mContext->vg)->userPtr; - auto glhandle = nvglImageHandleGL3(mContext->vg, handle.id); - - auto tex = glnvg__findTexture(nvgl, handle.id); - tex->tex = id; -} - -void Canvas::deleteImageHandle(ImageHandle image) { - if (image.id) { - nvgDeleteImage(mContext->vg, image.id); - } -} - -void Canvas::drawBegin() { - const auto size = mContext->window->getSize(); - nvgBeginFrame(mContext->vg, size.x, size.y, 1.0); - glViewport(0, 0, size.x, size.y); -} - -void Canvas::drawEnd() { - const auto size = mContext->window->getSize(); - glViewport(0, 0, size.x, size.y); - nvgEndFrame(mContext->vg); -} - - -void Canvas::colorWheel(const RectF& rec, const ColorWheel& colorWheel) { - auto NVG = mContext->vg; - const auto sizeInner = 5.f; - const auto sizeOuter = 7.f; - - float const x = rec.x + mOrigin.x; - float const y = rec.y + mOrigin.y; - float const w = rec.z; - float const h = rec.w; - - const HSV& hsv = colorWheel.color; - float const hue = hsv.h / (NVG_PI * 2); - - int i; - float r0, r1, ax, ay, bx, by, cx, cy, aeps, r; - NVGpaint paint; - - nvgSave(mContext->vg); - - cx = x + w * 0.5f; - cy = y + h * 0.5f; - r1 = (w < h ? w : h) * 0.5f - colorWheel.margin; - r0 = r1 - colorWheel.thickness; - - aeps = 0.5f / r1; // half a pixel arc length in radians (2pi cancels out). - - for (i = 0; i < 6; i++) { - float a0 = (float)i / 6.0f * NVG_PI * 2.0f - aeps; - float a1 = (float)(i + 1.0f) / 6.0f * NVG_PI * 2.0f + aeps; - nvgBeginPath(NVG); - nvgArc(NVG, cx, cy, r0, a0, a1, NVG_CW); - nvgArc(NVG, cx, cy, r1, a1, a0, NVG_CCW); - nvgClosePath(NVG); - ax = cx + cosf(a0) * (r0 + r1) * 0.5f; - ay = cy + sinf(a0) * (r0 + r1) * 0.5f; - bx = cx + cosf(a1) * (r0 + r1) * 0.5f; - by = cy + sinf(a1) * (r0 + r1) * 0.5f; - paint = nvgLinearGradient(NVG, ax, ay, bx, by, nvgHSLA(a0 / (NVG_PI * 2), 1.0f, 0.55f, 255), nvgHSLA(a1 / (NVG_PI * 2), 1.0f, 0.55f, 255)); - nvgFillPaint(NVG, paint); - nvgFill(NVG); - } - - nvgBeginPath(NVG); - nvgCircle(NVG, cx, cy, r0 - 0.5f); - nvgCircle(NVG, cx, cy, r1 + 0.5f); - nvgStrokeColor(NVG, nvgRGBA(0, 0, 0, 64)); - nvgStrokeWidth(NVG, 1.0f); - nvgStroke(NVG); - - // Selector - nvgSave(NVG); - nvgTranslate(NVG, cx, cy); - nvgRotate(NVG, hue * NVG_PI * 2); - - // Marker on - nvgStrokeWidth(NVG, 2.0f); - nvgBeginPath(NVG); - nvgRect(NVG, r0 - 1, -3, r1 - r0 + 2, 6); - nvgStrokeColor(NVG, nvgRGBA(255, 255, 255, 192)); - nvgStroke(NVG); - - paint = nvgBoxGradient(NVG, r0 - 3, -5, r1 - r0 + 6, 10, 2, 4, nvgRGBA(0, 0, 0, 128), nvgRGBA(0, 0, 0, 0)); - nvgBeginPath(NVG); - nvgRect(NVG, r0 - 2 - 10, -4 - 10, r1 - r0 + 4 + 20, 8 + 20); - nvgRect(NVG, r0 - 2, -4, r1 - r0 + 4, 8); - nvgPathWinding(NVG, NVG_HOLE); - nvgFillPaint(NVG, paint); - nvgFill(NVG); - - // Center triangle - r = r0 - 6; - ax = cosf(120.0f / 180.0f * NVG_PI) * r; - ay = sinf(120.0f / 180.0f * NVG_PI) * r; - bx = cosf(-120.0f / 180.0f * NVG_PI) * r; - by = sinf(-120.0f / 180.0f * NVG_PI) * r; - nvgBeginPath(NVG); - nvgMoveTo(NVG, r, 0); - nvgLineTo(NVG, ax, ay); - nvgLineTo(NVG, bx, by); - nvgClosePath(NVG); - paint = nvgLinearGradient(NVG, r, 0, ax, ay, nvgHSLA(hue, 1.0f, 0.5f, 255), nvgRGBA(255, 255, 255, 255)); - nvgFillPaint(NVG, paint); - nvgFill(NVG); - paint = nvgLinearGradient(NVG, (r + ax) * 0.5f, (0 + ay) * 0.5f, bx, by, nvgRGBA(0, 0, 0, 0), nvgRGBA(0, 0, 0, 255)); - nvgFillPaint(NVG, paint); - nvgFill(NVG); - nvgStrokeColor(NVG, nvgRGBA(0, 0, 0, 64)); - nvgStroke(NVG); - - // Select circle on triangle - float yt = hsv.v * hsv.s; - float xt = hsv.v - 0.5 * yt; - ay = sinf(120.0f / 180.0f * NVG_PI) * r * (-1.0f + xt * 2.0f); - ax = cosf(120.0f / 180.0f * NVG_PI) * r * (1.0f - yt * 3.f); - nvgStrokeWidth(NVG, 2.0f); - nvgBeginPath(NVG); - nvgCircle(NVG, ax, ay, sizeInner); - nvgStrokeColor(NVG, nvgRGBA(255, 255, 255, 192)); - nvgStroke(NVG); - - paint = nvgRadialGradient(NVG, ax, ay, 7, 9, nvgRGBA(0, 0, 0, 64), nvgRGBA(0, 0, 0, 0)); - nvgBeginPath(NVG); - nvgRect(NVG, ax - 20, ay - 20, 40, 40); - nvgCircle(NVG, ax, ay, sizeOuter); - nvgPathWinding(NVG, NVG_HOLE); - nvgFillPaint(NVG, paint); - nvgFill(NVG); - - nvgRestore(NVG); - - nvgRestore(NVG); -} - -void Canvas::ColorWheel::fromPoint(const tp::RectF& area, const tp::Vec2F& crs) { - auto wheel_rec = area; - wheel_rec.pos += margin; - wheel_rec.size -= margin * 2; - - auto center = wheel_rec.pos + wheel_rec.size / 2.f; - auto edge = min(wheel_rec.size.x, wheel_rec.size.y); - wheel_rec.pos = center - edge / 2.f; - wheel_rec.size = edge; - - HSV hsv = color; - - auto pos = crs - center; - - auto r = (crs - center).length(); - if (r < edge / 2.f) { - if (r > edge / 2.f - thickness) { - auto angle = halnf(atan2(pos.y, pos.x)); - angle = halnf(angle > 0 ? angle : PI * 2 + angle); - angle = clamp(angle, 0.f, halnf(PI * 2)); - hsv.h = angle; - } - else { - pos.y *= -1; - pos /= (edge / 2.f) - thickness - 10; - - auto angle = PI * 2 - hsv.h - PI / 2; - if (angle < 0) { - angle += PI * 2; - } - - auto sin = halnf(::sin(-angle)); - auto cos = halnf(::cos(-angle)); - auto sv_pos = Vec2F{ pos.x * cos - pos.y * sin, pos.x * sin + pos.y * cos }; - - auto sv_angle = halnf(atan2(sv_pos.x, sv_pos.y)); - sv_angle = halnf(sv_angle > 0 ? sv_angle : PI * 2 + sv_angle); - sv_angle = clamp(sv_angle, 0.f, halnf(PI * 2)); - - auto p1 = Vec2F(0, 1); - auto p2 = Vec2F(::cos(PI / 6), -::sin(PI / 6)); - auto p3 = Vec2F{ -p2.x, p2.y }; - - Vec2F intersection = sv_pos; - if (sv_angle < PI / 3 * 2) { - if (intersectLines2D({ 0, 0 }, sv_pos, p1, p2, &intersection)) { - sv_pos = intersection; - } - } - else if (sv_angle < PI / 3 * 4) { - if (intersectLines2D({ 0, 0 }, sv_pos, p3, p2, &intersection)) { - sv_pos = intersection; - } - } - else { - if (intersectLines2D({ 0, 0 }, sv_pos, p1, p3, &intersection)) { - sv_pos = intersection; - } - } - - sv_pos.y = halnf((sv_pos.y + 1 / 2.f) * 2 / 3.f * 1.0); - sv_pos.x = halnf(sv_pos.x * 2 / sqrt(3) * 1.0); - sv_pos.x = (sv_pos.x + 1.f) / 2; - - sv_pos.x = clamp(sv_pos.x, 0.f, 1.f); - sv_pos.y = clamp(sv_pos.y, 0.f, 1.f); - - auto v = sv_pos.y * 0.5f + sv_pos.x; - auto s = sv_pos.y / v; - - hsv.v = clamp(v, 0.001f, 0.999f); - hsv.s = clamp(s, 0.001f, 0.999f); - } - } - - color = hsv; -} \ No newline at end of file diff --git a/Graphics/private/DebugGUI.cpp b/Graphics/private/DebugGUI.cpp deleted file mode 100644 index 5880db11..00000000 --- a/Graphics/private/DebugGUI.cpp +++ /dev/null @@ -1,107 +0,0 @@ -#include "Window.hpp" -#include "WindowContext.hpp" - -#include "Graphics.hpp" - -// -------- Debug UI -------- // -#include -#include -#include -#include - -#include "implot.h" - -namespace tp { - class DebugGUI::Context { - public: - ImGuiIO* io{}; - ImGuiContext* ctx{}; - }; -} - -using namespace tp; - -DebugGUI::DebugGUI(Window* window) { - mContext = new Context(); - - IMGUI_CHECKVERSION(); - mContext->ctx = ImGui::CreateContext(); - mContext->io = &ImGui::GetIO(); - - // Initialize ImGui with GLFW and OpenGL - ImGui_ImplGlfw_InitForOpenGL(window->getContext()->window, true); - - // ImGui_ImplGlfw_GetBackendData(); - - ImGui_ImplOpenGL3_Init("#version 330"); - - ImGuiIO& io = ImGui::GetIO(); - io.Fonts->AddFontFromFileTTF("rsc/Font.ttf", 20.f); - - io.ConfigInputTrickleEventQueue = false; - - // appearance - io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; - - ImPlot::CreateContext(); - - return; - - auto& colors = ImGui::GetStyle().Colors; - colors[ImGuiCol_WindowBg] = ImVec4{ 0.1f, 0.105f, 0.11f, 1.0f }; - - // Headers - colors[ImGuiCol_Header] = ImVec4{ 0.2f, 0.205f, 0.21f, 1.0f }; - colors[ImGuiCol_HeaderHovered] = ImVec4{ 0.3f, 0.305f, 0.31f, 1.0f }; - colors[ImGuiCol_HeaderActive] = ImVec4{ 0.15f, 0.1505f, 0.151f, 1.0f }; - - // Buttons - colors[ImGuiCol_Button] = ImVec4{ 0.2f, 0.205f, 0.21f, 1.0f }; - colors[ImGuiCol_ButtonHovered] = ImVec4{ 0.3f, 0.305f, 0.31f, 1.0f }; - colors[ImGuiCol_ButtonActive] = ImVec4{ 0.15f, 0.1505f, 0.151f, 1.0f }; - - // Frame BG - colors[ImGuiCol_FrameBg] = ImVec4{ 0.2f, 0.205f, 0.21f, 1.0f }; - colors[ImGuiCol_FrameBgHovered] = ImVec4{ 0.3f, 0.305f, 0.31f, 1.0f }; - colors[ImGuiCol_FrameBgActive] = ImVec4{ 0.15f, 0.1505f, 0.151f, 1.0f }; - - // Tabs - colors[ImGuiCol_Tab] = ImVec4{ 0.15f, 0.1505f, 0.151f, 1.0f }; - colors[ImGuiCol_TabHovered] = ImVec4{ 0.38f, 0.3805f, 0.381f, 1.0f }; - colors[ImGuiCol_TabActive] = ImVec4{ 0.28f, 0.2805f, 0.281f, 1.0f }; - colors[ImGuiCol_TabUnfocused] = ImVec4{ 0.15f, 0.1505f, 0.151f, 1.0f }; - colors[ImGuiCol_TabUnfocusedActive] = ImVec4{ 0.2f, 0.205f, 0.21f, 1.0f }; - - // Title - colors[ImGuiCol_TitleBg] = ImVec4{ 0.15f, 0.1505f, 0.151f, 1.0f }; - colors[ImGuiCol_TitleBgActive] = ImVec4{ 0.15f, 0.1505f, 0.151f, 1.0f }; - colors[ImGuiCol_TitleBgCollapsed] = ImVec4{ 0.15f, 0.1505f, 0.151f, 1.0f }; -} - -DebugGUI::~DebugGUI() { - ImPlot::DestroyContext(); - - ImGui_ImplOpenGL3_Shutdown(); - ImGui_ImplGlfw_Shutdown(); - ImGui::DestroyContext(); - - - delete mContext; -} - -void DebugGUI::procBegin() { -} - -void DebugGUI::procEnd() { -} - -void DebugGUI::drawBegin() { - ImGui_ImplOpenGL3_NewFrame(); - ImGui_ImplGlfw_NewFrame(); - ImGui::NewFrame(); -} - -void DebugGUI::drawEnd() { - ImGui::Render(); - ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); -} \ No newline at end of file diff --git a/Graphics/private/EventHandler.cpp b/Graphics/private/EventHandler.cpp deleted file mode 100644 index 1f49e0db..00000000 --- a/Graphics/private/EventHandler.cpp +++ /dev/null @@ -1,156 +0,0 @@ - -#include "EventHandler.hpp" - -using namespace tp; - -EventHandler::EventHandler() { - mTimerEvent.reset(); -} - -EventHandler::~EventHandler() = default; - -void EventHandler::postEvent(InputID inputID, InputEvent inputEvent) { - mMutex.lock(); - mEventQueue.pushBack({ inputID, inputEvent }); - mMutex.unlock(); -} - -bool EventHandler::isEvents() { - mMutex.lock(); - auto res = mEventQueue.length(); - mMutex.unlock(); - - if (mTimerEvent.isTimeout()) { - mTimerEvent.reset(); - res = true; - } - - return res; -} - -InputState::State transitions[4][4] = { - { InputState::State::NONE, InputState::State::PRESSED, InputState::State::PRESSED, InputState::State::NONE }, - { InputState::State::PRESSED, InputState::State::HOLD, InputState::State::HOLD, InputState::State::HOLD }, - { InputState::State::HOLD, InputState::State::HOLD, InputState::State::HOLD, InputState::State::RELEASED }, - { InputState::State::NONE, InputState::State::NONE, InputState::State::NONE, InputState::State::NONE }, -}; - -bool transitionsReduce[4][4] = { - { true, false, false, true }, - { true, true, true, false }, - { true, true, true, false }, - { true, true, true, true }, -}; - -void EventHandler::processAllEvent() { - mMutex.lock(); - - mPointerPrev = mPointer; - - while (mEventQueue.size()) { - processEventUnguarded(); - } - mMutex.unlock(); -} - -void EventHandler::processEvent() { - mMutex.lock(); - - mPointerPrev = mPointer; - - if (!mEventQueue.size()) { - mMutex.unlock(); - return; - } - - processEventUnguarded(); - - mMutex.unlock(); -} - -void EventHandler::processEventUnguarded() { - - auto firstEvent = &mEventQueue.first(); - - const auto& eventData = firstEvent->second; - const auto& inputId = firstEvent->first; - - switch (eventData.type) { - case InputEvent::Type::MOUSE_POS: - { - mPointerPrev = mPointer; - mPointer = eventData.mouseEvent; - mEventQueue.popFront(); - break; - } - - case InputEvent::Type::BUTTON_ACTION: - { - auto currentState = (int) mInputStates[(int) inputId].mCurrentState; - auto reportedEvent = (int) eventData.buttonAction; - - mInputStates[(int) inputId].mCurrentState = transitions[currentState][reportedEvent]; - - if (transitionsReduce[currentState][reportedEvent]) { - mEventQueue.popFront(); - } - - break; - } - - case InputEvent::Type::SCROLL: - { - if (mScrollDelta == Vec2F { 0, 0 }) { - mScrollDelta = eventData.scrollDelta; - } else { - mEventQueue.popFront(); - mScrollDelta = 0; - } - break; - } - - default: - { - mEventQueue.popFront(); - } - } - - mPointerPressure = mInputStates[(int) InputID::MOUSE1].mCurrentState != InputState::State::NONE; -} - -void EventHandler::setCursorOrigin(const Vec2F& origin) { - mPointerOrigin = origin; -} - -Vec2F EventHandler::getPointer() const { return mPointer - mPointerOrigin; } - -Vec2F EventHandler::getPointerPrev() const { return mPointerPrev - mPointerOrigin; } - -bool EventHandler::isPressed(InputID id) const { - if (!mEnableKeyEvents) return false; - return mInputStates[(int) id].mCurrentState == InputState::State::PRESSED; -} - -bool EventHandler::isReleased(InputID id) const { - if (!mEnableKeyEvents) return false; - return mInputStates[(int) id].mCurrentState == InputState::State::RELEASED; -} - -halnf EventHandler::getPointerPressure() const { - if (!mEnableKeyEvents) return 0; - return mPointerPressure; -} - -bool EventHandler::isDown(InputID id) const { - if (!mEnableKeyEvents) return false; - return mInputStates[(int) id].mCurrentState == InputState::State::PRESSED || - mInputStates[(int) id].mCurrentState == InputState::State::HOLD; -} - -halnf EventHandler::getScrollY() const { return mScrollDelta.y; } - -Vec2F EventHandler::getPointerDelta() const { return mPointer - mPointerPrev; } - -void EventHandler::setEnableKeyEvents(bool enable) { - mEnableKeyEvents = enable; -} \ No newline at end of file diff --git a/Graphics/private/GraphicApplication.cpp b/Graphics/private/GraphicApplication.cpp deleted file mode 100644 index e59ddc78..00000000 --- a/Graphics/private/GraphicApplication.cpp +++ /dev/null @@ -1,146 +0,0 @@ - -#include "GraphicApplication.hpp" - -#include "imgui.h" - -using namespace tp; - -Application::Application() { - mWindow = Window::createWindow({1500, 900}); - mGraphics = new Graphics(mWindow); - - mDrawTimer.setDuration(1000.f / mDrawPerSecond); - mProcTimer.setDuration(1000.f / mProcPerSecond); - mPerSecondTimer.setDuration(1000.f); -} - -void Application::runDefaultLoop() { - auto eventHandler = new EventHandler(); - - mWindow->setEventHandler(eventHandler); - - mDrawTimer.reset(); - mProcTimer.reset(); - mPerSecondTimer.reset(); - - bool redrawNeeded = false; - - // proc first frame by default - { - mWindow->processEvents(false); - processFrame(eventHandler, 0); - - mGraphics->drawBegin(); - drawFrame(mGraphics->getCanvas()); - mGraphics->drawEnd(); - mWindow->draw(); - } - - time_ms prevProcTime = get_time(); - - bool isForcedNewFrame = true; - - while (!mWindow->shouldClose()) { - mWindow->processEvents(!isForcedNewFrame); - updateGlobalTime(); - - if (mProcTimer.isTimeout() || eventHandler->isEvents() || isForcedNewFrame) { - - while ((eventHandler->isEvents() || isForcedNewFrame)) { - - eventHandler->processEvent(); - - time_ms currentTime = get_time(); - processFrame(eventHandler, halnf(currentTime - prevProcTime)); - prevProcTime = currentTime; - - redrawNeeded = true; - mFramesProcessed++; - - isForcedNewFrame = forceNewFrame(); - - if (isForcedNewFrame) { - // mWindow->processEvents(); - break; - } - } - - mProcTimer.wait(); - mProcTimer.reset(); - } - - if (mDrawTimer.isTimeout()) { - mDrawTimer.reset(); - - if (redrawNeeded) { - mGraphics->drawBegin(); - - drawFrame(mGraphics->getCanvas()); - - mGraphics->drawEnd(); - - mWindow->draw(); - - redrawNeeded = false; - mFramesDrawn++; - } - } - - if (mPerSecondTimer.isTimeout()) { - mPerSecondTimer.reset(); - - mFramesProcessedPerSecond = mFramesProcessed; - mFramesDrawnPerSecond = mFramesDrawn; - - mFramesDrawn = 0; - mFramesProcessed = 0; - } - } - - delete eventHandler; -} - -void Application::runDebugLoop() { - auto eventHandler = new EventHandler(); - mWindow->setEventHandler(eventHandler); - - time_ms prevProcTime = get_time(); - - while (!mWindow->shouldClose()) { - mWindow->processEvents(); - eventHandler->processAllEvent(); - time_ms currentTime = get_time(); - processFrame(eventHandler, halnf(currentTime - prevProcTime)); - prevProcTime = currentTime; - - - mGraphics->drawBegin(); - drawFrame(mGraphics->getCanvas()); - mGraphics->drawEnd(); - mWindow->draw(); - } - - delete eventHandler; -} - -void Application::run() { - runDefaultLoop(); - // runDebugLoop(); -} - -void Application::processFrame(EventHandler* eventHandler, halnf deltaTime) {} - -void Application::drawFrame(Canvas* canvas) { - // ImGui::Text("Frames processed per second: %f", mFramesProcessedPerSecond); - // ImGui::Text("Frames drawn per second: %f", mFramesDrawnPerSecond); -} - -void Application::drawDebug() { - ImGui::Text("Frames processed per second: %f", this->mFramesProcessedPerSecond); - ImGui::Text("Frames drawn per second: %f", this->mFramesDrawnPerSecond); -} - -Application::~Application() { - delete mGraphics; - Window::destroyWindow(mWindow); -} \ No newline at end of file diff --git a/Graphics/private/Window.cpp b/Graphics/private/Window.cpp deleted file mode 100644 index ccaa39b3..00000000 --- a/Graphics/private/Window.cpp +++ /dev/null @@ -1,190 +0,0 @@ - -#include "Window.hpp" -#include "WindowContext.hpp" - -#include "Rect.hpp" - -#include - -#ifdef ENV_OS_WINDOWS -#define GLFW_EXPOSE_NATIVE_WIN32 -#endif - -#ifdef ENV_OS_LINUX -#define GLFW_EXPOSE_NATIVE_WAYLAND -#endif - -#include -#include - -using namespace tp; - -static void keyCallback(GLFWwindow* window, int key, int scancode, int action, int mods); -static void mouseButtonCallback(GLFWwindow* window, int button, int action, int mods); -static void scrollCallback(GLFWwindow* window, double xOffset, double yOffset); - -Window::Window(Vec2F size, const char* title) { - mContext = new Context(); - - // glfwWindowHint(GLFW_TRANSPARENT_FRAMEBUFFER, 1); - // glfwWindowHint(GLFW_DECORATED, GLFW_FALSE); - - mSize = size; - - // Create a window and OpenGL context - mContext->window = glfwCreateWindow((int) size.x, (int) size.y, title, nullptr, nullptr); - if (!mContext->window) { - printf("Failed to create GLFW window\n"); - return; - } - - glfwMakeContextCurrent(mContext->window); - - // Initialize GLEW - if (glewInit() != GLEW_OK) { - printf("Failed to initialize GLEW\n"); - exit(1); - return; - } - - // set event bindings - glfwSetWindowUserPointer(mContext->window, this); - glfwSetKeyCallback(mContext->window, keyCallback); - glfwSetMouseButtonCallback(mContext->window, mouseButtonCallback); - glfwSetScrollCallback(mContext->window, scrollCallback); -} - -Window::~Window() { - glfwDestroyWindow(mContext->window); - delete mContext; -} - -Window* Window::createWindow(Vec2F size, const char* title) { - static int count = 1; - if (!count) { - printf("Window class is a singleton\n"); - return nullptr; - } - count--; - - #ifdef ENV_OS_LINUX - glfwInitHint(GLFW_PLATFORM, GLFW_PLATFORM_X11); - #endif - // glfwInitHint(GLFW_WAYLAND_LIBDECOR, GLFW_WAYLAND_DISABLE_LIBDECOR); - - // Initialize GLFW - if (!glfwInit()) { - printf("Failed to initialize GLFW\n"); - return nullptr; - } - - // Set the GLFW error callback - glfwSetErrorCallback([](int error, const char* description) { - printf("GLFW Error: %i %s\n", error, description); - }); - - auto out = new Window(size, title); - - return out; -} - -void Window::destroyWindow(Window* window) { - delete window; - glfwTerminate(); -} - -bool Window::shouldClose() const { return glfwWindowShouldClose(mContext->window); } - -void Window::processEvents(bool wait) { - if (wait) glfwWaitEvents(); - else glfwPollEvents(); - checkAxisUpdates(); -} - -void Window::draw() { - glfwSwapBuffers(mContext->window); -} - -const Vec2F& Window::getSize() const { return mSize; } - -Window::Context* Window::getContext() { return mContext; } - -void Window::setEventHandler(EventHandler* eventHandler) { mEventHandler = eventHandler; } -EventHandler* Window::getEventHandler() { return mEventHandler; } - - -void Window::checkAxisUpdates() { - if (!mEventHandler) return; - - int w, h; - glfwGetWindowSize(mContext->window, &w, &h); - - if (mSize != Vec2F((halnf) w, (halnf) h)) { - mSize = { (halnf) w, (halnf) h }; - mEventHandler->postEvent(InputID::WINDOW_RESIZE, {}); - } - - double x, y; - glfwGetCursorPos(mContext->window, &x, &y); - - // int posX, posY; - // glfwGetWindowPos(mContext->window, &posX, &posY); - - if (mPointerPos != Vec2F{ (halnf) x, (halnf) y }) { - mPointerPos = { (halnf) x, (halnf) y }; - - // auto pos = Vec2F{ (halnf) posX, (halnf) posY }; - RectF windowRec = { { 0, 0 }, mSize }; - - if (windowRec.isInside(mPointerPos)) { - mEventHandler->postEvent(InputID::MOUSE1, { InputEvent::Type::MOUSE_POS, {}, mPointerPos }); - } - } -} - -static void keyCallback(GLFWwindow* window, int key, int scancode, int action, int mods) { - - auto* self = static_cast(glfwGetWindowUserPointer(window)); - if (!self) return; - - EventHandler* eventHandler = self->getEventHandler(); - if (!eventHandler) return; - - self->checkAxisUpdates(); - - if (action == GLFW_PRESS) { - eventHandler->postEvent((InputID) key, { InputEvent::Type::BUTTON_ACTION, InputEvent::ButtonAction::PRESS, {} }); - } else if (action == GLFW_RELEASE) { - eventHandler->postEvent((InputID) key, { InputEvent::Type::BUTTON_ACTION, InputEvent::ButtonAction::RELEASE, {} }); - } -} - -static void mouseButtonCallback(GLFWwindow* window, int button, int action, int mods) { - auto* self = static_cast(glfwGetWindowUserPointer(window)); - if (!self) return; - - EventHandler* eventHandler = self->getEventHandler(); - if (!eventHandler) return; - - self->checkAxisUpdates(); - - auto id = (InputID) ((int) InputID::MOUSE1 + button); - - if (action == GLFW_PRESS) { - // printf("mouse\n"); - eventHandler->postEvent(id, { InputEvent::Type::BUTTON_ACTION, InputEvent::ButtonAction::PRESS, {} }); - } else if (action == GLFW_RELEASE) { - eventHandler->postEvent(id, { InputEvent::Type::BUTTON_ACTION, InputEvent::ButtonAction::RELEASE, {} }); - } - -} - -static void scrollCallback(GLFWwindow* window, double xOffset, double yOffset) { - auto* self = static_cast(glfwGetWindowUserPointer(window)); - if (!self) return; - - EventHandler* eventHandler = self->getEventHandler(); - if (!eventHandler) return; - - eventHandler->postEvent(InputID::SCROLL, { InputEvent::Type::SCROLL, {}, {}, { xOffset, yOffset } }); -} \ No newline at end of file diff --git a/Graphics/private/WindowContext.hpp b/Graphics/private/WindowContext.hpp deleted file mode 100644 index c237297b..00000000 --- a/Graphics/private/WindowContext.hpp +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include "Window.hpp" - -struct GLFWwindow; - -namespace tp { - - class Window::Context { - public: - Context() = default; - GLFWwindow* window = nullptr; - }; -} \ No newline at end of file diff --git a/Graphics/public/EventHandler.hpp b/Graphics/public/EventHandler.hpp deleted file mode 100644 index c62bd896..00000000 --- a/Graphics/public/EventHandler.hpp +++ /dev/null @@ -1,116 +0,0 @@ -#pragma once - -#include "InputCodes.hpp" -#include "Vec.hpp" -#include "List.hpp" -#include "Map.hpp" -#include "Timing.hpp" - -#include - -namespace tp { - - struct InputEvent { - enum class Type { - NONE = 0, - BUTTON_ACTION, - MOUSE_DELTA, - MOUSE_POS, - SCROLL, - } type = Type::NONE; - - enum class ButtonAction { - NONE = 0, - PRESS, - HOLD, - RELEASE, - REPEAT, - } buttonAction = ButtonAction::NONE; - - Vec2F mouseEvent = { 0, 0 }; - Vec2F scrollDelta = { 0, 0 }; - }; - - class InputState { - public: - InputState() = default; - - void handleEvent(const InputEvent& event); - - bool isStateChanged(); - - // true if event is no longer needed and false if we need to emulate some transitions further - bool isHandled(); - - public: - // Transitions from state to state is strict and defined by state order in the enum - // If posted event conflicts with that automata, we need to emulate intermediate states - enum State { - NONE = 0, // Button is inactive - PRESSED, // Button is pressed - HOLD, // Button is still pressed - RELEASED, // Button is released - }; - - State mCurrentState; - State mPreviousState; - }; - - // Assumes that user and event poster use different threads - // User can add own states for each input and decide how this state changes on specific events - // Event posters has no access to any custom state and only report any changes in any way - class EventHandler { - public: - EventHandler(); - ~EventHandler(); - - public: // Event Poster Interface - // Record event - void postEvent(InputID inputID, InputEvent inputEvent); - - public: // User interface - bool isEvents(); - void processEvent(); - void processAllEvent(); - - void setCursorOrigin(const Vec2F& origin); - - [[nodiscard]] Vec2F getPointer() const; - [[nodiscard]] Vec2F getPointerPrev() const; - [[nodiscard]] Vec2F getPointerDelta() const; - - [[nodiscard]] bool isPressed(InputID id) const; - [[nodiscard]] bool isReleased(InputID id) const; - [[nodiscard]] bool isDown(InputID id) const; - [[nodiscard]] halnf getScrollY() const; - - [[nodiscard]] halnf getPointerPressure() const; - - void setEnableKeyEvents(bool); - - private: - void processEventUnguarded(); - - private: - std::mutex mMutex = {}; - - // Store thread protected queue of posted events - List> mEventQueue; - - // input states - Vec2F mPointerOrigin = { 0, 0 }; - - Vec2F mPointer; - Vec2F mPointerPrev; - Vec2F mScrollDelta; - - halnf mPointerPressure = 0; - - InputState mInputStates[(int) InputID::LAST_KEY_CODE]{}; - - Timer mTimerEvent = Timer(1000); - - bool mEnableKeyEvents = true; - }; - -} \ No newline at end of file diff --git a/Graphics/public/GraphicApplication.hpp b/Graphics/public/GraphicApplication.hpp deleted file mode 100644 index 253314d0..00000000 --- a/Graphics/public/GraphicApplication.hpp +++ /dev/null @@ -1,47 +0,0 @@ - -#pragma once - -#include "Window.hpp" -#include "Graphics.hpp" -#include "Timing.hpp" - -namespace tp { - class Application { - public: - Application(); - - void run(); - - virtual bool forceNewFrame() { return false; } - - virtual void processFrame(EventHandler* eventHandler, halnf deltaTime); - virtual void drawFrame(Canvas* canvas); - - virtual ~Application(); - - void drawDebug(); - - private: - void runDefaultLoop(); - void runDebugLoop(); - - protected: - bool mInitialized = false; - - ualni mDrawPerSecond = 1160; - ualni mProcPerSecond = 1300; - - Timer mDrawTimer; - Timer mProcTimer; - Timer mPerSecondTimer; - - halnf mFramesProcessedPerSecond = 0; - halnf mFramesDrawnPerSecond = 0; - - halnf mFramesProcessed = 0; - halnf mFramesDrawn = 0; - - Graphics* mGraphics = nullptr; - Window* mWindow = nullptr; - }; -} \ No newline at end of file diff --git a/Graphics/public/Graphics.hpp b/Graphics/public/Graphics.hpp deleted file mode 100644 index afbc3256..00000000 --- a/Graphics/public/Graphics.hpp +++ /dev/null @@ -1,124 +0,0 @@ -#pragma once - -#include "Window.hpp" - -#include "Color.hpp" -#include "Rect.hpp" - -#include "Buffer.hpp" - -namespace tp { - - class DebugGUI { - class Context; - Context* mContext; - - public: - explicit DebugGUI(Window* window); - ~DebugGUI(); - - void procBegin(); - void procEnd(); - - void drawBegin(); - void drawEnd(); - }; - - class Canvas { - class Context; - Context* mContext; - - public: - explicit Canvas(Window* window); - ~Canvas(); - - void procBegin() {} - void procEnd() {} - - void drawBegin(); - void drawEnd(); - - public: - enum Align : int2 { - CC = 0x0000, - CT = 0x0001, - CB = 0x0002, - LC = 0x0100, - LT = 0x0101, - LB = 0x0102, - RC = 0x0200, - RT = 0x0201, - RB = 0x0202, - }; - - struct ImageHandle { - ualni id = 0; - }; - - struct ColorWheel { - HSV color = { 0.13, 0.5, 1 }; - halnf thickness = 20; - halnf margin = 5; - - void fromPoint(const RectF& area, const Vec2F& point); - }; - - void setOrigin(const Vec2F& origin); - - void pushClamp(const RectF& rec); - void popClamp(); - [[nodiscard]] const RectF& getClampedArea() const; - - void debugCross(RectF rec, const RGBA& col); - void rect(RectF rec, const RGBA& col, halnf round = 0); - void frame(RectF rec, const RGBA& col, halnf round = 0); - void circle(Vec2F pos, halnf size, const RGBA& col); - void text(const char*, const RectF&, halnf size, Align, halnf padding, const RGBA&); - void colorWheel(const RectF& rec, const ColorWheel& colorWheel); - void line(Vec2F start, Vec2F end, const RGBA& col, halnf thickness); - - ImageHandle createImageFromTextId(ualni id, Vec2F size); - void updateTextureID(ImageHandle handle, ualni id); - - void deleteImageHandle(ImageHandle image); - void drawImage(RectF rec, ImageHandle* image, halnf angle = 0, halnf alpha = 1.f, halnf rounding = 0.f); - - private: - Buffer mScissors; - bool mIsClamping = false; - Vec2F mOrigin = { 0, 0 }; - }; - - class Graphics { - public: - explicit Graphics(Window* window) : - mGui(window), - mCanvas(window) {} - - void procBegin() { - mCanvas.procBegin(); - mGui.procBegin(); - } - - void procEnd() { - mCanvas.procEnd(); - mGui.procEnd(); - } - - void drawBegin() { - mCanvas.drawBegin(); - mGui.drawBegin(); - } - - void drawEnd() { - mCanvas.drawEnd(); - mGui.drawEnd(); - } - - Canvas* getCanvas() { return &mCanvas; } - - private: - Canvas mCanvas; - DebugGUI mGui; - }; -} \ No newline at end of file diff --git a/Graphics/public/InputCodes.hpp b/Graphics/public/InputCodes.hpp deleted file mode 100644 index 1cc8f401..00000000 --- a/Graphics/public/InputCodes.hpp +++ /dev/null @@ -1,152 +0,0 @@ - -#pragma once - -#include "Environment.hpp" - -namespace tp { - enum class InputID : ualni { - /* Printable keys */ - SPACE = 32, - APOSTROPHE = 39, /* ' */ - COMMA = 44, /* , */ - MINUS = 45, /* - */ - PERIOD = 46, /* . */ - SLASH = 0xBF, /* \ */ - N0 = 48, - N1 = 49, - N2 = 50, - N3 = 51, - N4 = 52, - N5 = 53, - N6 = 54, - N7 = 55, - N8 = 56, - N9 = 57, - SEMICOLON = 59, /* ; */ - EQUAL = 61, /* = */ - A = 65, - B = 66, - C = 67, - D = 68, - E = 69, - F = 70, - G = 71, - H = 72, - I = 73, - J = 74, - K = 75, - L = 76, - M = 77, - N = 78, - O = 79, - P = 80, - Q = 81, - R = 82, - S = 83, - T = 84, - U = 85, - V = 86, - W = 87, - X = 88, - Y = 89, - Z = 90, - LEFT_BRACKET = 91, /* [ */ - BACKSLASH = 92, /* \ */ - RIGHT_BRACKET = 93, /* ] */ - GRAVE_ACCENT = 96, /* ` */ - WORLD_1 = 161, /* non-US #1 */ - WORLD_2 = 162, /* non-US #2 */ - - /* function keys */ - - ESCAPE = 256, - ENTER = 257, - TAB = 258, - BACKSPACE = 259, - INSERT = 260, - DELETE_KEY = 261, - RIGHT = 262, - LEFT = 263, - DOWN = 264, - UP = 265, - PAGE_UP = 266, - PAGE_DOWN = 267, - HOME = 268, - END = 269, - CAPS_LOCK = 280, - SCROLL_LOCK = 281, - NUM_LOCK = 282, - PRINT_SCREEN = 283, - PAUSE = 284, - F1 = 290, - F2 = 291, - F3 = 292, - F4 = 293, - F5 = 294, - F6 = 295, - F7 = 296, - F8 = 297, - F9 = 298, - F10 = 299, - F11 = 300, - F12 = 301, - F13 = 302, - F14 = 303, - F15 = 304, - F16 = 305, - F17 = 306, - F18 = 307, - F19 = 308, - F20 = 309, - F21 = 310, - F22 = 311, - F23 = 312, - F24 = 313, - F25 = 314, - KP_0 = 320, - KP_1 = 321, - KP_2 = 322, - KP_3 = 323, - KP_4 = 324, - KP_5 = 325, - KP_6 = 326, - KP_7 = 327, - KP_8 = 328, - KP_9 = 329, - KP_DECIMAL = 330, - KP_DIVIDE = 331, - KP_MULTIPLY = 332, - KP_SUBTRACT = 333, - KP_ADD = 334, - KP_ENTER = 335, - KP_EQUAL = 336, - LEFT_SHIFT = 340, - LEFT_CONTROL = 341, - LEFT_ALT = 342, - LEFT_SUPER = 343, - RIGHT_SHIFT = 344, - RIGHT_CONTROL = 345, - RIGHT_ALT = 346, - RIGHT_SUPER = 347, - MENU = 348, - - INV_SLASH = 0xDC, - BRA = 0xDB, - KET = 0xDD, - TILDA = 0xC0, - DOUBLE_DOT = 0xBA, - QUOTES = 0xDE, - - MOUSE1 = 501, - MOUSE2 = 502, - MOUSE3 = 503, - MOUSE4 = 504, - MOUSE5 = 505, - - SCROLL, - - WINDOW_RESIZE, - - LAST_KEY_CODE, - }; -} \ No newline at end of file diff --git a/Graphics/public/Window.hpp b/Graphics/public/Window.hpp deleted file mode 100644 index c37d6563..00000000 --- a/Graphics/public/Window.hpp +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include "Buffer.hpp" -#include "EventHandler.hpp" - -namespace tp { - class Window { - class Context; - - private: - Window(Vec2F size, const char* title); - ~Window(); - - public: - static Window* createWindow(Vec2F size = { 1000.f, 700.f }, const char* title = "Window"); - static void destroyWindow(Window* window); - - public: - void draw(); - void processEvents(bool wait = true); - - void setEventHandler(EventHandler* eventHandler); - [[nodiscard]] EventHandler* getEventHandler(); - - Context* getContext(); - [[nodiscard]] bool shouldClose() const; - [[nodiscard]] const Vec2F& getSize() const; - - void checkAxisUpdates(); - - private: - Vec2F mSize; - Vec2F mPointerPos; - - Context* mContext; - EventHandler* mEventHandler = nullptr; - }; -} \ No newline at end of file diff --git a/LibraryViewer/CMakeLists.txt b/LibraryViewer/CMakeLists.txt index 4400a13a..babae03a 100644 --- a/LibraryViewer/CMakeLists.txt +++ b/LibraryViewer/CMakeLists.txt @@ -11,13 +11,13 @@ file(GLOB HEADERS "./public/*.hpp" "./public/*/*.hpp" "./applications/*.hpp") add_library(${PROJECT_NAME} STATIC ${SOURCES} ${HEADERS}) target_include_directories(${PROJECT_NAME} PUBLIC ./public/ ${BINDINGS_INCLUDE} ./ext/) -target_link_libraries(${PROJECT_NAME} PUBLIC Graphics Connection Widgets) +target_link_libraries(${PROJECT_NAME} PUBLIC GraphicApplication Connection Widgets) target_link_libraries(${PROJECT_NAME} PUBLIC ${BINDINGS_LIBS}) file(COPY "library" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}") -file(COPY "applications/Font.ttf" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/rsc/") ### -------------------------- Applications -------------------------- ### add_executable(libView ./applications/Entry.cpp) target_link_libraries(libView ${PROJECT_NAME}) +graphic_application_resources(libView) diff --git a/LibraryViewer/applications/Font.ttf b/LibraryViewer/applications/Font.ttf deleted file mode 100644 index 8a630544..00000000 Binary files a/LibraryViewer/applications/Font.ttf and /dev/null differ diff --git a/LibraryViewer/private/Player.cpp b/LibraryViewer/private/Player.cpp index 81202bb6..bb442046 100644 --- a/LibraryViewer/private/Player.cpp +++ b/LibraryViewer/private/Player.cpp @@ -3,6 +3,8 @@ #include "portaudio.h" +#include + class DeviceStream { friend class Player; diff --git a/LibraryViewer/public/GUI.hpp b/LibraryViewer/public/GUI.hpp index 10a1ad76..8e8e93a4 100644 --- a/LibraryViewer/public/GUI.hpp +++ b/LibraryViewer/public/GUI.hpp @@ -23,7 +23,7 @@ namespace tp { getLayout()->setMinSize({ 70, 70 }); }; - void process(const EventHandler& events) override { + void process(const InputView& events) override { mState = IDLE; if (!mTrack) { @@ -35,7 +35,7 @@ namespace tp { case HOVER: case IDLE: mState = getArea().relative().isInside(events.getPointer()) ? HOVER : IDLE; - if (events.isPressed(InputID::MOUSE1)) { + if (events.isPressed(PointerButton::Left)) { mState = SELECTED; } break; @@ -68,8 +68,8 @@ namespace tp { const RectF textAreaName = { textArea.x, textArea.y, textArea.z, textArea.w * 0.5f }; const RectF textAreaAuthor = { textArea.x, textArea.y + textArea.w * 0.5f, textArea.z, textArea.w * 0.5f }; - canvas.text(mTrack->mName.c_str(), textAreaName, 15.f, Canvas::LC, rounding, colorNameTrack); - canvas.text(mTrack->mArtist.c_str(), textAreaAuthor, 12.f, Canvas::LC, rounding, colorNameArtist); + canvas.text(mTrack->mName.c_str(), textAreaName, { .size = 15.f, .color = colorNameTrack, .padding = rounding }); + canvas.text(mTrack->mArtist.c_str(), textAreaAuthor, { .size = 12.f, .color = colorNameArtist, .padding = rounding }); } [[nodiscard]] bool processesEvents() const override { return true; } @@ -245,7 +245,7 @@ namespace tp { } } - void process(const EventHandler& events) override { + void process(const InputView& events) override { filter(); for (auto track : mSongList.getContainer()->getChildren()) { diff --git a/LibraryViewer/public/Library.hpp b/LibraryViewer/public/Library.hpp index 7b34d429..d2472382 100644 --- a/LibraryViewer/public/Library.hpp +++ b/LibraryViewer/public/Library.hpp @@ -1,5 +1,6 @@ #pragma once +#include "Buffer.hpp" #include "Window.hpp" typedef tp::ualni SongId; diff --git a/Math/public/Vec.hpp b/Math/public/Vec.hpp index 0729046b..dd674ce1 100644 --- a/Math/public/Vec.hpp +++ b/Math/public/Vec.hpp @@ -375,6 +375,14 @@ namespace tp { return sqrt(tmp); } + [[nodiscard]] halnf angle() const { return (halnf) atan2(y, x); } + + [[nodiscard]] Vec rotated(halnf angle) const { + const halnf cosine = (halnf) cos(angle); + const halnf sine = (halnf) sin(angle); + return { (Type) (x * cosine - y * sine), (Type) (x * sine + y * cosine) }; + } + // Vector Transformation void Rot(float cosA, float sinA) { Type tmp = x; diff --git a/Math/tests/Tests.cpp b/Math/tests/Tests.cpp index 5625baec..b3a70deb 100644 --- a/Math/tests/Tests.cpp +++ b/Math/tests/Tests.cpp @@ -101,3 +101,18 @@ SUITE(DisplayGamma) { CHECK_EQUAL(1.0f, linearToGamma(2)); } } + +SUITE(Vec2) { + TEST(AngleIsMeasuredFromThePositiveXAxis) { + CHECK_CLOSE(0.0f, Vec2F(2, 0).angle(), 1e-6f); + CHECK_CLOSE(1.5707963f, Vec2F(0, 3).angle(), 1e-6f); + CHECK_CLOSE(-1.5707963f, Vec2F(0, -1).angle(), 1e-6f); + } + + TEST(RotatedTurnsCounterClockwiseAndKeepsLength) { + const Vec2F turned = Vec2F(1, 0).rotated(1.5707963f); + CHECK_CLOSE(0.0f, turned.x, 1e-6f); + CHECK_CLOSE(1.0f, turned.y, 1e-6f); + CHECK_CLOSE(Vec2F(3, 4).length(), Vec2F(3, 4).rotated(0.7f).length(), 1e-5f); + } +} diff --git a/Objects/CMakeLists.txt b/Objects/CMakeLists.txt index d3e0d91a..d377f040 100644 --- a/Objects/CMakeLists.txt +++ b/Objects/CMakeLists.txt @@ -24,9 +24,9 @@ add_executable(osg ./applications/GUI.cpp ./applications/GUIEntry.cpp) #target_link_libraries(osc ${PROJECT_NAME}) #target_link_libraries(osi ${PROJECT_NAME}) -target_link_libraries(osg ${PROJECT_NAME} Graphics Imgui) +target_link_libraries(osg ${PROJECT_NAME} GraphicApplication Imgui) +graphic_application_resources(osg) -file(COPY "rsc/Font.ttf" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/") ### -------------------------- Tests -------------------------- ### file(GLOB TEST_SOURCES "./tests/*.cpp" "./tests/*/*.cpp") diff --git a/Objects/applications/GUIEntry.cpp b/Objects/applications/GUIEntry.cpp index 8eef964f..30cb37a4 100644 --- a/Objects/applications/GUIEntry.cpp +++ b/Objects/applications/GUIEntry.cpp @@ -6,19 +6,23 @@ using namespace tp; using namespace obj; -class SimpleGUI : public Application { +class SimpleGUI : public ApplicationDelegate { public: - SimpleGUI() { gui.cd(objects_api::create(), "root"); } + SimpleGUI() : + mApplication(*this) { + gui.cd(objects_api::create(), "root"); + } - void processFrame(EventHandler* eventHandler, halnf delta) override {} + void run() { mApplication.run(); } - void drawFrame(Canvas* canvas) override { - canvas->rect({ { 0, 0 }, mWindow->getSize() }, RGBA(0.f, 0.f, 0.f, 1.f), 0); + void drawFrame(Canvas& canvas) override { + canvas.rect({ { 0, 0 }, mApplication.window().contentSize() }, RGBA(0.f, 0.f, 0.f, 1.f), 0); gui.draw(); } private: ObjectsGUI gui; + Application mApplication; }; int main() { diff --git a/Objects/rsc/Font.ttf b/Objects/rsc/Font.ttf deleted file mode 100644 index 8a630544..00000000 Binary files a/Objects/rsc/Font.ttf and /dev/null differ diff --git a/Sketch3D/CMakeLists.txt b/Sketch3D/CMakeLists.txt index b5eb9f81..a28c3dc8 100644 --- a/Sketch3D/CMakeLists.txt +++ b/Sketch3D/CMakeLists.txt @@ -11,12 +11,12 @@ file(GLOB HEADERS "./public/*.hpp" "./public/*/*.hpp") add_library(${PROJECT_NAME} STATIC ${SOURCES} ${HEADERS}) target_include_directories(${PROJECT_NAME} PUBLIC ./public/ ${BINDINGS_INCLUDE} ./ext/) -target_link_libraries(${PROJECT_NAME} PUBLIC Graphics Connection Widgets Math RasterRender) +target_link_libraries(${PROJECT_NAME} PUBLIC GraphicApplication Connection Widgets Math RasterRender) target_link_libraries(${PROJECT_NAME} PUBLIC ${BINDINGS_LIBS}) ### -------------------------- Applications -------------------------- ### add_executable(Sketch3DApp ./applications/Entry.cpp) target_link_libraries(Sketch3DApp ${PROJECT_NAME}) +graphic_application_resources(Sketch3DApp) file(COPY "rsc" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/") -file(COPY "applications/Font.ttf" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/rsc") diff --git a/Sketch3D/applications/Entry.cpp b/Sketch3D/applications/Entry.cpp index ef799c8b..e2e02740 100644 --- a/Sketch3D/applications/Entry.cpp +++ b/Sketch3D/applications/Entry.cpp @@ -11,7 +11,7 @@ public: Sketch3DApplication() { setRoot(&mGui); - mGui.createRenderWidget(mGraphics->getCanvas(), { 2560, 1440 }); + mGui.createRenderWidget(&canvas(), { 2560, 1440 }); mGui.setProject(&mSketch); } diff --git a/Sketch3D/applications/Font.ttf b/Sketch3D/applications/Font.ttf deleted file mode 100644 index 8a630544..00000000 Binary files a/Sketch3D/applications/Font.ttf and /dev/null differ diff --git a/Sketch3D/private/SketchGUI.cpp b/Sketch3D/private/SketchGUI.cpp index 91c76bb8..b7d986b6 100644 --- a/Sketch3D/private/SketchGUI.cpp +++ b/Sketch3D/private/SketchGUI.cpp @@ -5,7 +5,7 @@ using namespace tp; SketchRenderWidget::SketchRenderWidget(Canvas& canvas, Vec2F renderResolution) : mRenderer(renderResolution) { - mImage = canvas.createImageFromTextId(mRenderer.getBuff()->texId(), mRenderer.getBuff()->getSize()); + mImage = canvas.createImageFromRenderTarget(mRenderer.getBuff()->texId()); mCanvas = &canvas; setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); @@ -13,13 +13,13 @@ SketchRenderWidget::SketchRenderWidget(Canvas& canvas, Vec2F renderResolution) : setDebug("render", {}); } -SketchRenderWidget::~SketchRenderWidget() { mCanvas->deleteImageHandle(mImage); } +SketchRenderWidget::~SketchRenderWidget() { mCanvas->deleteImage(mImage); } void SketchRenderWidget::draw(Canvas& canvas) { auto area = getArea().relative(); if (mProject) { mRenderer.renderToTexture(mProject, area.size); - canvas.drawImage(area, &mImage, 0, 1, 12); + canvas.drawImage(area, mImage, 0, 1, 12); } } @@ -36,7 +36,7 @@ void SketchViewportWidget::setColor(const RGBA& color) { void SketchViewportWidget::setRenderWidget(SketchRenderWidget* widget) { addChild(widget); } -void SketchViewportWidget::process(const EventHandler& events) { +void SketchViewportWidget::process(const InputView& events) { if (!mProject) return; auto area = getArea().relative(); @@ -49,11 +49,11 @@ void SketchViewportWidget::process(const EventHandler& events) { crs.y /= area.w; crs = (crs - 0.5) * 2; - if (events.isPressed(InputID::MOUSE1)) { + if (events.isPressed(PointerButton::Left)) { mAction = true; lockFocus(); - } else if (events.isReleased(InputID::MOUSE1)) { + } else if (events.isReleased(PointerButton::Left)) { mAction = false; freeFocus(); } @@ -65,11 +65,11 @@ void SketchViewportWidget::process(const EventHandler& events) { Vec2F relativePosPrev = ((mActionPosAbsolutePrev / area.size) - 0.5f) * 2.f; Vec2F relativeDelta = relativePos - relativePosPrev; - if (events.isDown(InputID::LEFT_SHIFT)) { + if (events.isDown(Key::LeftShift)) { mProject->mCamera.move(relativePos, relativePosPrev); - } else if (events.isDown(InputID::LEFT_ALT)) { + } else if (events.isDown(Key::LeftAlt)) { mProject->mCamera.rotate(-relativeDelta.x * halnf(PI), -relativeDelta.y * halnf(PI)); - } else if (events.isDown(InputID::LEFT_CONTROL)) { + } else if (events.isDown(Key::LeftControl)) { halnf factor = pointer.y / mActionPosAbsolutePrev.y; mProject->mCamera.zoom(factor); } else { @@ -170,14 +170,14 @@ void SketchGUI::setProject(Project* project) { SketchGUI::~SketchGUI() { delete mRenderWidget; } -void SketchGUI::process(const EventHandler& events) { +void SketchGUI::process(const InputView& events) { DockWidget::process(events); if (!mProject) return; auto pencil = dynamic_cast(mProject->mBrushes.get("pencil")); - pencil->mCol = mColorPicker.mColorWheel.color; + pencil->mCol = mColorPicker.color(); pencil->mSize = mBrushSizeSlider.val() * 0.2f + 0.001f; } diff --git a/Sketch3D/public/SketchGUI.hpp b/Sketch3D/public/SketchGUI.hpp index f4770b2d..b232a8e2 100644 --- a/Sketch3D/public/SketchGUI.hpp +++ b/Sketch3D/public/SketchGUI.hpp @@ -19,7 +19,7 @@ namespace tp { private: Renderer mRenderer; Project* mProject = nullptr; - Canvas::ImageHandle mImage; + ImageHandle mImage; Canvas* mCanvas = nullptr; }; @@ -33,7 +33,7 @@ namespace tp { void setRenderWidget(SketchRenderWidget* widget); void setToolbarWidget(Widget* widget); - void process(const EventHandler& events) override; + void process(const InputView& events) override; [[nodiscard]] bool processesEvents() const override { return true; } private: @@ -65,7 +65,7 @@ namespace tp { void setProject(Project* project); ~SketchGUI() override; - void process(const EventHandler& events) override; + void process(const InputView& events) override; void draw(Canvas& canvas) override; private: diff --git a/Widgets/CMakeLists.txt b/Widgets/CMakeLists.txt index c4a68a5a..80d060bb 100644 --- a/Widgets/CMakeLists.txt +++ b/Widgets/CMakeLists.txt @@ -6,12 +6,12 @@ file(GLOB HEADERS "./public/*.hpp") add_library(${PROJECT_NAME} STATIC ${SOURCES} ${HEADERS}) target_include_directories(${PROJECT_NAME} PUBLIC ./public/ ./public/layouts ./public/mangers ./public/widgets) -target_link_libraries(${PROJECT_NAME} PUBLIC Math Graphics Imgui) +target_link_libraries(${PROJECT_NAME} PUBLIC Math GraphicApplication Imgui) ### -------------------------- Applications -------------------------- ### add_executable(WidgetsExample examples/Example.cpp) target_link_libraries(WidgetsExample ${PROJECT_NAME} ${GLEW_LIB}) +graphic_application_resources(WidgetsExample) target_include_directories(WidgetsExample PUBLIC ../Externals/glfw/include ${GLEW_INCLUDE_DIR}) -file(COPY "examples/Font.ttf" DESTINATION "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/rsc") diff --git a/Widgets/examples/Font.ttf b/Widgets/examples/Font.ttf deleted file mode 100644 index 8a630544..00000000 Binary files a/Widgets/examples/Font.ttf and /dev/null differ diff --git a/Widgets/private/RootWidget.cpp b/Widgets/private/RootWidget.cpp index 41697b6f..c05047fa 100644 --- a/Widgets/private/RootWidget.cpp +++ b/Widgets/private/RootWidget.cpp @@ -34,7 +34,7 @@ void RootWidget::setRootWidget(Widget* widget) { mUserRoot = widget; } -void RootWidget::processFrame(EventHandler* events, const RectF& screenArea) { +void RootWidget::processFrame(const InputState& input, const RectF& screenArea) { mRoot.setArea(screenArea); gDebugWidget.mUpdManager.addSample(TimerWrapper([&]() { @@ -45,7 +45,7 @@ void RootWidget::processFrame(EventHandler* events, const RectF& screenArea) { updateAreaCache(&mRoot, true); // update all events and call all event processing callbacks - mUpdateManager.processWidgets(&mRoot, *events); + mUpdateManager.processWidgets(&mRoot, input); updateAreaCache(&mRoot, true); }).exec()); @@ -58,7 +58,7 @@ void RootWidget::processFrame(EventHandler* events, const RectF& screenArea) { updateAreaCache(&mRoot, false); // trigger some widgets by moise pointer - mUpdateManager.handleFocusChanges(&mRoot, *events); + mUpdateManager.handleFocusChanges(&mRoot, InputView(input)); // check triggered widgets for removal mUpdateManager.clean(); @@ -77,22 +77,20 @@ void RootWidget::drawFrame(Canvas& canvas) { void RootWidget::drawRecursion(Canvas& canvas, Widget* active, const Vec2F& pos) { if (!active->mFlags.get(ENABLED)) return; - canvas.setOrigin(pos); - canvas.pushClamp({ pos, active->getArea().size }); + { + const Canvas::Placement placement(canvas, pos - canvas.origin(), active->getArea().size); + if (!canvas.isClippedAway()) { + if (active->getArea().sizeVecW() > active->getArea().pos) { + active->draw(canvas); + } - if (canvas.getClampedArea().size.length2() > EPSILON) { - if (active->getArea().sizeVecW() > active->getArea().pos) { - active->draw(canvas); - } - - for (auto child = active->mDepthOrder.lastNode(); child; child = child->prev) { - drawRecursion(canvas, child->data, pos + child->data->getArea().pos); + for (auto child = active->mDepthOrder.lastNode(); child; child = child->prev) { + drawRecursion(canvas, child->data, pos + child->data->getArea().pos); + } } } - canvas.setOrigin(pos); - canvas.popClamp(); - + const Canvas::Translation overlay(canvas, pos - canvas.origin()); active->drawOverlay(canvas); } diff --git a/Widgets/private/WidgetApplication.cpp b/Widgets/private/WidgetApplication.cpp index 70e5aa0e..c3888aca 100644 --- a/Widgets/private/WidgetApplication.cpp +++ b/Widgets/private/WidgetApplication.cpp @@ -4,6 +4,9 @@ using namespace tp; +WidgetApplication::WidgetApplication(const ApplicationOptions& options) : + mApplication(*this, options) {} + void WidgetApplication::debugUI() { // pass } @@ -11,13 +14,13 @@ void WidgetApplication::debugUI() { void WidgetApplication::setRoot(Widget* widget) { mRootWidget.setRootWidget(widget); - tp::RootWidget::setWidgetArea(*widget, {{}, mWindow->getSize()}); + tp::RootWidget::setWidgetArea(*widget, { {}, window().contentSize() }); } -void WidgetApplication::processFrame(EventHandler* eventHandler, halnf deltaTime) { - const auto area = RectF({ 0, 0 }, mWindow->getSize()); +void WidgetApplication::processFrame(const InputState& input, halnf) { + const auto area = RectF({ 0, 0 }, window().contentSize()); - gDebugWidget.update(&mRootWidget, *eventHandler); + gDebugWidget.update(&mRootWidget, InputView(input)); if (gDebugWidget.isDebug()) { mGuiArea = area.splitByFactorHL(mDebugSplitFactor); @@ -29,13 +32,13 @@ void WidgetApplication::processFrame(EventHandler* eventHandler, halnf deltaTime if (gDebugWidget.isFrozen()) return; Timer timer; - mRootWidget.processFrame(eventHandler, mGuiArea); + mRootWidget.processFrame(input, mGuiArea); gDebugWidget.mProcTime.addSample(timer.timePassed()); } -void WidgetApplication::drawFrame(Canvas* canvas) { +void WidgetApplication::drawFrame(Canvas& canvas) { Timer timer; - mRootWidget.drawFrame(*canvas); + mRootWidget.drawFrame(canvas); gDebugWidget.mDrawTime.addSample(timer.timePassed()); if (gDebugWidget.isDebug()) { @@ -48,15 +51,15 @@ void WidgetApplication::drawFrame(Canvas* canvas) { ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoDecoration )) { - drawDebug(); + mApplication.showFrameRates(); debugUI(); - gDebugWidget.drawDebug(&mRootWidget, *canvas); + gDebugWidget.drawDebug(&mRootWidget, canvas); } ImGui::End(); } } -bool WidgetApplication::forceNewFrame() { +bool WidgetApplication::wantsFrame() { return mRootWidget.needsUpdate(); } \ No newline at end of file diff --git a/Widgets/private/managers/DebugManager.cpp b/Widgets/private/managers/DebugManager.cpp index 921f7e1d..77986f62 100644 --- a/Widgets/private/managers/DebugManager.cpp +++ b/Widgets/private/managers/DebugManager.cpp @@ -14,37 +14,35 @@ DebugManager tp::gDebugWidget; #define LIST_SIZE \ { -FLT_MIN, 150 } -void DebugManager::update(RootWidget* rootWidget, EventHandler& events) { +void DebugManager::update(RootWidget* rootWidget, const InputView& events) { mRootWidget = rootWidget; - events.setEnableKeyEvents(true); - if (events.isPressed(InputID::D)) mDebug = !mDebug; + if (events.isPressed(Key::D)) mDebug = !mDebug; if (mDebug) { - events.setEnableKeyEvents(true); - if (events.isPressed(InputID::K)) mDebugStopProcessing = !mDebugStopProcessing; + if (events.isPressed(Key::K)) mDebugStopProcessing = !mDebugStopProcessing; if (auto widget = rootWidget->mUpdateManager.mInFocusWidget) { if (auto lay = dynamic_cast(widget->getLayout())) { - if (events.isPressed(InputID::V)) lay->setLayoutPolicy(LayoutPolicy::Vertical); - if (events.isPressed(InputID::H)) lay->setLayoutPolicy(LayoutPolicy::Horizontal); + if (events.isPressed(Key::V)) lay->setLayoutPolicy(LayoutPolicy::Vertical); + if (events.isPressed(Key::H)) lay->setLayoutPolicy(LayoutPolicy::Horizontal); auto sizing = lay->getSizePolicy(); - if (events.isDown(InputID::X)) { - if (events.isPressed(InputID::S)) lay->setSizePolicy(SizePolicy::Minimal, sizing.y); - if (events.isPressed(InputID::E)) lay->setSizePolicy(SizePolicy::Expanding, sizing.y); + if (events.isDown(Key::X)) { + if (events.isPressed(Key::S)) lay->setSizePolicy(SizePolicy::Minimal, sizing.y); + if (events.isPressed(Key::E)) lay->setSizePolicy(SizePolicy::Expanding, sizing.y); } - if (events.isDown(InputID::Y)) { - if (events.isPressed(InputID::S)) lay->setSizePolicy(sizing.x, SizePolicy::Minimal); - if (events.isPressed(InputID::E)) lay->setSizePolicy(sizing.x, SizePolicy::Expanding); + if (events.isDown(Key::Y)) { + if (events.isPressed(Key::S)) lay->setSizePolicy(sizing.x, SizePolicy::Minimal); + if (events.isPressed(Key::E)) lay->setSizePolicy(sizing.x, SizePolicy::Expanding); } } } if (auto breakWidget = rootWidget->mUpdateManager.mInFocusWidget) { - if (events.isPressed(InputID::P)) mProcBreakpoints.insert(breakWidget); - if (events.isPressed(InputID::L)) mLayBreakpoints.insert(breakWidget); + if (events.isPressed(Key::P)) mProcBreakpoints.insert(breakWidget); + if (events.isPressed(Key::L)) mLayBreakpoints.insert(breakWidget); } } } @@ -110,7 +108,11 @@ void DebugManager::recursiveDraw(Canvas& canvas, Widget* active, const Vec2F& po if (active->isUpdate()) { RGBA color = { 1, 0, 0, 1 }; canvas.frame(area, color); - canvas.text((active->mDebug.id + ":" + std::to_string(depthOrder)).c_str(), area, 22, Canvas::Align::LC, 2, color); + canvas.text( + (active->mDebug.id + ":" + std::to_string(depthOrder)).c_str(), + area, + { .size = 22, .color = color, .padding = 2 } + ); } if (active->mFlags.get(Widget::IN_FOCUS)) { diff --git a/Widgets/private/managers/UpdateManager.cpp b/Widgets/private/managers/UpdateManager.cpp index d1af2cd6..55c03688 100644 --- a/Widgets/private/managers/UpdateManager.cpp +++ b/Widgets/private/managers/UpdateManager.cpp @@ -3,16 +3,9 @@ using namespace tp; -void UpdateManager::processWidgets(Widget* root, EventHandler& events) { - events.setEnableKeyEvents(true); - events.setCursorOrigin({ 0, 0 }); - - processFocusItems(events); - - events.setEnableKeyEvents(false); - events.setCursorOrigin({ 0, 0 }); - - processActiveTree(root, events, { 0, 0 }); +void UpdateManager::processWidgets(Widget* root, const InputState& input) { + processFocusItems(input); + processActiveTree(root, input, { 0, 0 }); } void UpdateManager::clean() { @@ -69,14 +62,12 @@ void UpdateManager::getWidgetPath(Widget* widget, std::vector& out) { } } -void UpdateManager::handleFocusChanges(Widget* root, EventHandler& events) { +void UpdateManager::handleFocusChanges(Widget* root, const InputView& input) { auto prevFocus = mInFocusWidget; - events.setCursorOrigin({ 0, 0 }); - mInFocusWidget = nullptr; - findFocusWidget(root, &mInFocusWidget, events.getPointer()); + findFocusWidget(root, &mInFocusWidget, input.getPointer()); if (mFocusLockWidget) { bool hasLockedWidget = false; @@ -147,7 +138,7 @@ void UpdateManager::findFocusWidget(Widget* iter, Widget** focus, const Vec2F& p } } -void UpdateManager::processActiveTree(Widget* iter, EventHandler& events, Vec2F parent) { +void UpdateManager::processActiveTree(Widget* iter, const InputState& input, Vec2F parent) { if (!iter || !iter->isUpdate()) return; auto current = parent + iter->getAreaT().pos; @@ -155,16 +146,15 @@ void UpdateManager::processActiveTree(Widget* iter, EventHandler& events, Vec2F if (!iter->mFlags.get(Widget::IN_FOCUS)) { iter->mDebug.pGlobal = current; - events.setCursorOrigin(current); - procWidget(iter, events, false); + procWidget(iter, InputView(input, current, InputView::Buttons::Hidden)); } for (auto child : iter->mDepthOrder) { - processActiveTree(child.data(), events, current); + processActiveTree(child.data(), input, current); } } -void UpdateManager::processFocusItems(EventHandler& events) { +void UpdateManager::processFocusItems(const InputState& input) { if (!mInFocusWidget) return; std::vector path; @@ -191,23 +181,18 @@ void UpdateManager::processFocusItems(EventHandler& events) { for (int iter = (int) len - 1; iter >= 0; iter--) { auto widget = path[iter]; - events.setCursorOrigin(widgetGlobalPos[iter]); - widget->mDebug.pGlobal = widgetGlobalPos[iter]; - if (!eventsProcessed && widget->processesEvents()) { - procWidget(widget, events, true); - eventsProcessed = true; - } else { - procWidget(widget, events, false); - } + const bool receivesButtons = !eventsProcessed && widget->processesEvents(); + const InputView::Buttons buttons = receivesButtons ? InputView::Buttons::Visible : InputView::Buttons::Hidden; + procWidget(widget, InputView(input, widgetGlobalPos[iter], buttons)); + eventsProcessed = eventsProcessed || receivesButtons; } } -void UpdateManager::procWidget(Widget* widget, EventHandler& events, bool withEvents) { - events.setEnableKeyEvents(withEvents); +void UpdateManager::procWidget(Widget* widget, const InputView& input) { gDebugWidget.checkProcBreakPoints(widget); - widget->process(events); + widget->process(input); } void UpdateManager::lockFocus(tp::Widget* widget) { diff --git a/Widgets/private/widgets/AnimationTestWidget.cpp b/Widgets/private/widgets/AnimationTestWidget.cpp index 8b09d576..6c93c50f 100644 --- a/Widgets/private/widgets/AnimationTestWidget.cpp +++ b/Widgets/private/widgets/AnimationTestWidget.cpp @@ -2,12 +2,12 @@ using namespace tp; -void AnimationTestWidget::process(const EventHandler& events) { - if (events.isDown(tp::InputID::MOUSE1)) { +void AnimationTestWidget::process(const InputView& events) { + if (events.isDown(PointerButton::Left)) { mTestSpring.getStart().setTargetPosition(events.getPointer()); } - if (events.isDown(tp::InputID::MOUSE2)) { + if (events.isDown(PointerButton::Right)) { mTestSpring.getEnd().setTargetPosition(events.getPointer()); } diff --git a/Widgets/private/widgets/ColorPickerWidget.cpp b/Widgets/private/widgets/ColorPickerWidget.cpp index 1f5c0187..08a598c7 100644 --- a/Widgets/private/widgets/ColorPickerWidget.cpp +++ b/Widgets/private/widgets/ColorPickerWidget.cpp @@ -1,22 +1,154 @@ #include "ColorPickerWidget.hpp" +#include +#include +#include + using namespace tp; -void RGBPickerWidget::process(const EventHandler& events) { - if (events.isPressed(InputID::MOUSE1)) { - mColorWheel.fromPoint(getArea().relative(), events.getPointer()); +namespace { + constexpr halnf kTwoPi = 2 * std::numbers::pi_v; + constexpr halnf kThirdTurn = kTwoPi / 3; + constexpr halnf kRingThickness = 20; + constexpr halnf kWheelMargin = 5; + constexpr halnf kTriangleInset = 6; + constexpr int kRingSegments = 6; + + const RGBA kEdgeShade = { 0, 0, 0, 0.25f }; + const RGBA kMarkerColor = { 1, 1, 1, 0.75f }; + const RGBA kMarkerShadow = { 0, 0, 0, 0.25f }; + const RGBA kTransparent = { 0, 0, 0, 0 }; + + RGBA colorOf(halnf hue, halnf saturation, halnf value) { + RGBA color; + color = HSV(hue, saturation, value); + return color; + } + + Vec2F onCircle(Vec2F center, halnf radius, halnf angle) { return center + Vec2F(radius, 0.0f).rotated(angle); } + + Vec2F saturationValueAt(Vec2F unitTrianglePoint) { + const halnf whiteToHue = std::clamp((1 - unitTrianglePoint.x / std::cos(kThirdTurn)) / 3, 0.0f, 1.0f); + const halnf blackToWhite = (unitTrianglePoint.y / std::sin(kThirdTurn) + 1) / 2; + const halnf insideTriangle = std::clamp(blackToWhite, whiteToHue / 2, 1 - whiteToHue / 2); + + const halnf value = insideTriangle + whiteToHue / 2; + const halnf saturation = value > 0 ? whiteToHue / value : 0; + return { std::clamp(saturation, 0.0f, 1.0f), std::clamp(value, 0.0f, 1.0f) }; + } + + Vec2F unitTrianglePointOf(halnf saturation, halnf value) { + const halnf whiteToHue = saturation * value; + const halnf blackToWhite = value - whiteToHue / 2; + return { (1 - 3 * whiteToHue) * std::cos(kThirdTurn), (2 * blackToWhite - 1) * std::sin(kThirdTurn) }; + } +} + +halnf RGBPickerWidget::Geometry::innerRadius() const { return outerRadius - kRingThickness; } + +halnf RGBPickerWidget::Geometry::triangleRadius() const { return innerRadius() - kTriangleInset; } + +RGBPickerWidget::Geometry RGBPickerWidget::geometry() const { + const RectF area = getArea().relative(); + return { area.center(), std::min(area.z, area.w) / 2 - kWheelMargin }; +} + +RGBPickerWidget::Part RGBPickerWidget::partAt(Vec2F point) const { + const Geometry wheel = geometry(); + const halnf distance = (halnf) (point - wheel.center).length(); + + if (distance > wheel.outerRadius) { + return Part::Outside; + } + if (distance >= wheel.innerRadius()) { + return Part::HueRing; + } + return Part::SaturationValueTriangle; +} + +void RGBPickerWidget::pick(Vec2F point) { + const Geometry wheel = geometry(); + const Vec2F offset = point - wheel.center; + + if (mDragging == Part::HueRing) { + const halnf angle = offset.angle(); + mColor.h = angle >= 0 ? angle : angle + kTwoPi; + } else if (mDragging == Part::SaturationValueTriangle) { + const Vec2F saturationValue = saturationValueAt(offset.rotated(-mColor.h) / wheel.triangleRadius()); + mColor.s = saturationValue.x; + mColor.v = saturationValue.y; + } +} + +void RGBPickerWidget::process(const InputView& events) { + if (events.isPressed(PointerButton::Left)) { + mDragging = partAt(events.getPointer()); + pick(events.getPointer()); lockFocus(); } - if (events.isReleased(InputID::MOUSE1)) { + if (events.isReleased(PointerButton::Left)) { + mDragging = Part::Outside; freeFocus(); } - if (events.getPointerDelta().length() > EPSILON && events.isDown(InputID::MOUSE1)) { - mColorWheel.fromPoint(getArea().relative(), events.getPointer()); + if (events.getPointerDelta().length() > EPSILON && events.isDown(PointerButton::Left)) { + pick(events.getPointer()); } } void RGBPickerWidget::draw(Canvas& canvas) { - canvas.colorWheel(getArea().relative(), mColorWheel); + const Geometry wheel = geometry(); + + drawHueRing(canvas, wheel); + drawHueMarker(canvas, wheel); + drawSaturationValueTriangle(canvas, wheel); + drawSaturationValueMarker(canvas, wheel); +} + +void RGBPickerWidget::drawHueRing(Canvas& canvas, const Geometry& wheel) const { + const halnf seamOverlap = 0.5f / wheel.outerRadius; + const halnf middleRadius = (wheel.innerRadius() + wheel.outerRadius) / 2; + + for (int segment = 0; segment < kRingSegments; segment++) { + const halnf from = (halnf) segment / kRingSegments * kTwoPi; + const halnf to = (halnf) (segment + 1) / kRingSegments * kTwoPi; + const Canvas::LinearGradient hues = { + onCircle(wheel.center, middleRadius, from), + onCircle(wheel.center, middleRadius, to), + colorOf(from, 1, 1), + colorOf(to, 1, 1), + }; + canvas.ringSegment(wheel.center, wheel.innerRadius(), wheel.outerRadius, from - seamOverlap, to + seamOverlap, hues); + } + + canvas.circleOutline(wheel.center, wheel.innerRadius(), kEdgeShade, 1); + canvas.circleOutline(wheel.center, wheel.outerRadius + 1, kEdgeShade, 1); +} + +void RGBPickerWidget::drawHueMarker(Canvas& canvas, const Geometry& wheel) const { + const halnf middleRadius = (wheel.innerRadius() + wheel.outerRadius) / 2; + const Vec2F marker = onCircle(wheel.center, middleRadius, mColor.h); + + canvas.radialGlow(marker, kRingThickness / 2, kRingThickness / 2 + 4, kMarkerShadow, kTransparent); + canvas.circleOutline(marker, kRingThickness / 2, kMarkerColor, 2); +} + +void RGBPickerWidget::drawSaturationValueTriangle(Canvas& canvas, const Geometry& wheel) const { + const halnf radius = wheel.triangleRadius(); + const Vec2F hueCorner = onCircle(wheel.center, radius, mColor.h); + const Vec2F whiteCorner = onCircle(wheel.center, radius, mColor.h + kThirdTurn); + const Vec2F blackCorner = onCircle(wheel.center, radius, mColor.h - kThirdTurn); + const Vec2F triangle[] = { hueCorner, whiteCorner, blackCorner }; + + canvas.polygon(triangle, 3, Canvas::LinearGradient{ hueCorner, whiteCorner, colorOf(mColor.h, 1, 1), { 1, 1, 1, 1 } }); + canvas.polygon(triangle, 3, Canvas::LinearGradient{ (hueCorner + whiteCorner) / 2, blackCorner, kTransparent, { 0, 0, 0, 1 } }); +} + +void RGBPickerWidget::drawSaturationValueMarker(Canvas& canvas, const Geometry& wheel) const { + const Vec2F unitPoint = unitTrianglePointOf(mColor.s, mColor.v); + const Vec2F marker = wheel.center + (unitPoint * wheel.triangleRadius()).rotated(mColor.h); + + canvas.radialGlow(marker, 7, 9, kMarkerShadow, kTransparent); + canvas.circleOutline(marker, 5, kMarkerColor, 2); } diff --git a/Widgets/private/widgets/DockWidget.cpp b/Widgets/private/widgets/DockWidget.cpp index b75a35c5..e28fd913 100644 --- a/Widgets/private/widgets/DockWidget.cpp +++ b/Widgets/private/widgets/DockWidget.cpp @@ -51,7 +51,7 @@ void DockWidget::toggleWidgetVisibility(DockLayout::Side side) { } } -void DockWidget::process(const EventHandler& events) { +void DockWidget::process(const InputView& events) { // calculateHeaderAreas(); Widget* floater = nullptr; @@ -79,12 +79,12 @@ void DockWidget::process(const EventHandler& events) { if (!mPreviewWidget) { layout()->updateResizeHover(events.getPointer()); - if (events.isPressed(InputID::MOUSE1)) { + if (events.isPressed(PointerButton::Left)) { if (layout()->startResize(events.getPointer())) { triggerWidgetUpdate("dock layout resizing"); lockFocus(); } - } else if (events.isReleased(InputID::MOUSE1)) { + } else if (events.isReleased(PointerButton::Left)) { layout()->endResize(); freeFocus(); } diff --git a/Widgets/private/widgets/FloatingWidget.cpp b/Widgets/private/widgets/FloatingWidget.cpp index 0a8579ad..e95e53c8 100644 --- a/Widgets/private/widgets/FloatingWidget.cpp +++ b/Widgets/private/widgets/FloatingWidget.cpp @@ -3,17 +3,17 @@ using namespace tp; -void FloatingWidget::process(const EventHandler& events) { +void FloatingWidget::process(const InputView& events) { const auto pointer = events.getPointer(); - if (getRelativeAreaT().isInside(pointer) && events.isPressed(InputID::MOUSE1)) { + if (getRelativeAreaT().isInside(pointer) && events.isPressed(PointerButton::Left)) { layout()->startAction(pointer); bringToFront(); lockFocus(); } - if (layout()->isFloating() && events.isReleased(InputID::MOUSE1)) { + if (layout()->isFloating() && events.isReleased(PointerButton::Left)) { layout()->endAction(); freeFocus(); } diff --git a/Widgets/private/widgets/ScrollableWidget.cpp b/Widgets/private/widgets/ScrollableWidget.cpp index e67f0fac..fe6d669c 100644 --- a/Widgets/private/widgets/ScrollableWidget.cpp +++ b/Widgets/private/widgets/ScrollableWidget.cpp @@ -4,7 +4,7 @@ using namespace tp; -void ScrollableBarWidget::process(const EventHandler& events) { +void ScrollableBarWidget::process(const InputView& events) { // all content is visible no need to process anything if (mSizeFactor >= 1) { mScrolling = false; @@ -20,7 +20,7 @@ void ScrollableBarWidget::process(const EventHandler& events) { mHandleHovered = getHandleRect().isInside(pointer); - if (getRelativeAreaT().isInside(pointer) && events.isPressed(InputID::MOUSE1)) { + if (getRelativeAreaT().isInside(pointer) && events.isPressed(PointerButton::Left)) { if (!mHandleHovered) { jumpTo((pointer / size)[mVertical]); mStartPos = { 0, 0 }; @@ -31,7 +31,7 @@ void ScrollableBarWidget::process(const EventHandler& events) { lockFocus(); } - if (mScrolling && events.isReleased(InputID::MOUSE1)) { + if (mScrolling && events.isReleased(PointerButton::Left)) { mScrolling = false; freeFocus(); } diff --git a/Widgets/private/widgets/SimpleWidgets.cpp b/Widgets/private/widgets/SimpleWidgets.cpp index 3407fa4e..4b763d96 100644 --- a/Widgets/private/widgets/SimpleWidgets.cpp +++ b/Widgets/private/widgets/SimpleWidgets.cpp @@ -12,7 +12,7 @@ const std::string& LabelWidget::getText() const { } void LabelWidget::draw(Canvas& canvas) { - canvas.text(mText.c_str(), getRelativeArea(), mSize, Canvas::LC, mPadding, mColor); + canvas.text(mText.c_str(), getRelativeArea(), { .size = mSize, .color = mColor, .padding = mPadding }); } ButtonWidget::ButtonWidget() { @@ -36,9 +36,9 @@ void ButtonWidget::setColor(const RGBA& in) { triggerWidgetUpdate("color changed"); } -void ButtonWidget::process(const EventHandler& eventHandler) { - if (getRelativeArea().isInside(eventHandler.getPointer())) { - if (eventHandler.isPressed(InputID::MOUSE1)) { +void ButtonWidget::process(const InputView& inputState) { + if (getRelativeArea().isInside(inputState.getPointer())) { + if (inputState.isPressed(PointerButton::Left)) { mAction(); } } @@ -81,12 +81,12 @@ void ButtonWidget::mouseLeave() { } -void SliderWidget::process(const EventHandler& events) { +void SliderWidget::process(const InputView& events) { const auto pointer = events.getPointer(); switch (mState) { case SLIDING: - if (events.isReleased(InputID::MOUSE1)) { + if (events.isReleased(PointerButton::Left)) { mState = IDLE; freeFocus(); } @@ -98,7 +98,7 @@ void SliderWidget::process(const EventHandler& events) { case IDLE: case HOVER: mState = getHandleArea().isInside(pointer) ? HOVER : IDLE; - if (getRelativeAreaT().isInside(pointer) && events.isPressed(InputID::MOUSE1)) { + if (getRelativeAreaT().isInside(pointer) && events.isPressed(PointerButton::Left)) { mState = SLIDING; lockFocus(); } @@ -138,7 +138,7 @@ void PopupWidget::open(Widget* parent, const RectF& at) { parent->openPopup(this); } -void PopupWidget::process(const EventHandler& events) { +void PopupWidget::process(const InputView& events) { bool insideParent = mParentArea.isInside(events.getPointer()); bool insideMenu = getArea().relative().isInside(events.getPointer()); diff --git a/Widgets/public/RootWidget.hpp b/Widgets/public/RootWidget.hpp index d66b1b07..5d72c8e1 100644 --- a/Widgets/public/RootWidget.hpp +++ b/Widgets/public/RootWidget.hpp @@ -20,7 +20,7 @@ namespace tp { // Graphic Application Interface public: - void processFrame(EventHandler* events, const RectF& screenArea); + void processFrame(const InputState& input, const RectF& screenArea); void drawFrame(Canvas& canvas); [[nodiscard]] bool needsUpdate() const; diff --git a/Widgets/public/Widget.hpp b/Widgets/public/Widget.hpp index c44d7183..1defb36c 100644 --- a/Widgets/public/Widget.hpp +++ b/Widgets/public/Widget.hpp @@ -4,8 +4,9 @@ #include "Layout.hpp" -#include "EventHandler.hpp" -#include "Graphics.hpp" +#include "InputState.hpp" +#include "Canvas.hpp" +#include "List.hpp" #include #include @@ -73,7 +74,7 @@ namespace tp { void freeFocus(); protected: - virtual void process(const EventHandler& events) {} + virtual void process(const InputView& events) {} virtual void draw(Canvas& canvas) {} virtual void drawOverlay(Canvas& canvas) {} virtual void endAnimations(); diff --git a/Widgets/public/WidgetApplication.hpp b/Widgets/public/WidgetApplication.hpp index 7bf6fbef..c328ff8a 100644 --- a/Widgets/public/WidgetApplication.hpp +++ b/Widgets/public/WidgetApplication.hpp @@ -5,27 +5,31 @@ #include "Timing.hpp" namespace tp { - class WidgetApplication : public Application { + class WidgetApplication : public ApplicationDelegate { public: - WidgetApplication() = default; + explicit WidgetApplication(const ApplicationOptions& options = {}); + + void run() { mApplication.run(); } + [[nodiscard]] Application& application() { return mApplication; } + [[nodiscard]] Window& window() { return mApplication.window(); } + [[nodiscard]] Canvas& canvas() { return mApplication.canvas(); } void setRoot(Widget* widget); virtual void debugUI(); private: - void processFrame(EventHandler* eventHandler, halnf deltaTime) override; - - void drawFrame(Canvas* canvas) override; - bool forceNewFrame() override ; - + void processFrame(const InputState& input, halnf deltaMs) override; + void drawFrame(Canvas& canvas) override; + bool wantsFrame() override; private: halnf mDebugSplitFactor = 0.7; RootWidget mRootWidget; + Application mApplication; private: RectF mGuiArea; RectF mDebugArea; }; -} \ No newline at end of file +} diff --git a/Widgets/public/mangers/DebugManager.hpp b/Widgets/public/mangers/DebugManager.hpp index 0b4ce929..72390a79 100644 --- a/Widgets/public/mangers/DebugManager.hpp +++ b/Widgets/public/mangers/DebugManager.hpp @@ -1,5 +1,6 @@ #pragma once +#include "Timing.hpp" #include "Widget.hpp" #include @@ -54,7 +55,7 @@ namespace tp { [[nodiscard]] bool isFrozen() const { return mDebugStopProcessing; } [[nodiscard]] bool isRedrawAlways() const { return mDebugRedrawAlways; } - void update(RootWidget* rootWidget, EventHandler& events); + void update(RootWidget* rootWidget, const InputView& events); void drawDebug(RootWidget* rootWidget, Canvas& canvas); void checkProcBreakPoints(Widget* widget) { diff --git a/Widgets/public/mangers/UpdateManager.hpp b/Widgets/public/mangers/UpdateManager.hpp index 33c5ecf3..b6e3252b 100644 --- a/Widgets/public/mangers/UpdateManager.hpp +++ b/Widgets/public/mangers/UpdateManager.hpp @@ -21,19 +21,19 @@ namespace tp { return !mTriggeredWidgets.empty(); } - void processWidgets(Widget* root, EventHandler& eventHandler); + void processWidgets(Widget* root, const InputState& input); void clean(); void updateTreeToProcess(Widget* root); - void handleFocusChanges(Widget* root, EventHandler& events); + void handleFocusChanges(Widget* root, const InputView& input); void findFocusWidget(Widget* iter, Widget** focus, const Vec2F& pointer); static void getWidgetPath(Widget* widget, std::vector& out); - void processActiveTree(Widget* iter, EventHandler& events, Vec2F pos); - void processFocusItems(EventHandler& events); + void processActiveTree(Widget* iter, const InputState& input, Vec2F pos); + void processFocusItems(const InputState& input); private: - static void procWidget(Widget* widget, EventHandler& events, bool withEvents = false); + static void procWidget(Widget* widget, const InputView& input); private: std::map mTriggeredWidgets; diff --git a/Widgets/public/widgets/AnimationTestWidget.hpp b/Widgets/public/widgets/AnimationTestWidget.hpp index dd3f66c4..0c5851d2 100644 --- a/Widgets/public/widgets/AnimationTestWidget.hpp +++ b/Widgets/public/widgets/AnimationTestWidget.hpp @@ -10,7 +10,7 @@ namespace tp { ~AnimationTestWidget() override = default; void draw(Canvas& canvas) override; - void process(const EventHandler& events) override; + void process(const InputView& events) override; [[nodiscard]] bool needsNextFrame() const override; diff --git a/Widgets/public/widgets/ColorPickerWidget.hpp b/Widgets/public/widgets/ColorPickerWidget.hpp index e9d592c0..a6b40410 100644 --- a/Widgets/public/widgets/ColorPickerWidget.hpp +++ b/Widgets/public/widgets/ColorPickerWidget.hpp @@ -7,12 +7,34 @@ namespace tp { public: RGBPickerWidget() = default; - void process(const EventHandler& events) override; + void process(const InputView& events) override; void draw(Canvas& canvas) override; [[nodiscard]] bool processesEvents() const override { return true; } - public: - Canvas::ColorWheel mColorWheel; + [[nodiscard]] const HSV& color() const { return mColor; } + + private: + enum class Part { Outside, HueRing, SaturationValueTriangle }; + + struct Geometry { + Vec2F center; + halnf outerRadius = 0; + + [[nodiscard]] halnf innerRadius() const; + [[nodiscard]] halnf triangleRadius() const; + }; + + [[nodiscard]] Geometry geometry() const; + [[nodiscard]] Part partAt(Vec2F point) const; + void pick(Vec2F point); + + void drawHueRing(Canvas& canvas, const Geometry& wheel) const; + void drawHueMarker(Canvas& canvas, const Geometry& wheel) const; + void drawSaturationValueTriangle(Canvas& canvas, const Geometry& wheel) const; + void drawSaturationValueMarker(Canvas& canvas, const Geometry& wheel) const; + + HSV mColor = { 0.13f, 0.5f, 1 }; + Part mDragging = Part::Outside; }; -} \ No newline at end of file +} diff --git a/Widgets/public/widgets/DockWidget.hpp b/Widgets/public/widgets/DockWidget.hpp index 0e07f2fa..91ff0a8f 100644 --- a/Widgets/public/widgets/DockWidget.hpp +++ b/Widgets/public/widgets/DockWidget.hpp @@ -6,7 +6,7 @@ namespace tp { DockWidget(); public: - void process(const EventHandler& events) override; + void process(const InputView& events) override; void draw(Canvas& canvas) override; void drawOverlay(Canvas& canvas) override; diff --git a/Widgets/public/widgets/FloatingWidget.hpp b/Widgets/public/widgets/FloatingWidget.hpp index 71f16865..c8e096c0 100644 --- a/Widgets/public/widgets/FloatingWidget.hpp +++ b/Widgets/public/widgets/FloatingWidget.hpp @@ -12,7 +12,7 @@ namespace tp { setLayout(new FloatingLayout(this)); } - void process(const EventHandler& events) override; + void process(const InputView& events) override; void draw(Canvas& canvas) override; diff --git a/Widgets/public/widgets/ScrollableWidget.hpp b/Widgets/public/widgets/ScrollableWidget.hpp index 1903df20..bcb67ad1 100644 --- a/Widgets/public/widgets/ScrollableWidget.hpp +++ b/Widgets/public/widgets/ScrollableWidget.hpp @@ -12,7 +12,7 @@ namespace tp { [[nodiscard]] bool propagateEventsToChildren() const override { return false; } [[nodiscard]] bool needsNextFrame() const override { return mScrolling; } - void process(const EventHandler& events) override; + void process(const InputView& events) override; void draw(Canvas& canvas) override; void updateSizeFactor(halnf factor); // holder-size divided by content-size diff --git a/Widgets/public/widgets/SimpleWidgets.hpp b/Widgets/public/widgets/SimpleWidgets.hpp index b1036cc1..c4c6d79b 100644 --- a/Widgets/public/widgets/SimpleWidgets.hpp +++ b/Widgets/public/widgets/SimpleWidgets.hpp @@ -33,7 +33,7 @@ namespace tp { void setAction(const std::function& action); - void process(const EventHandler& eventHandler) override; + void process(const InputView& inputState) override; void draw(Canvas& canvas) override; void mouseEnter() override; @@ -67,7 +67,7 @@ namespace tp { public: SliderWidget() = default; - void process(const EventHandler& eventHandler) override; + void process(const InputView& inputState) override; void draw(Canvas& canvas) override; [[nodiscard]] bool processesEvents() const override { return true; } @@ -97,7 +97,7 @@ namespace tp { PopupWidget() = default; public: - void process(const EventHandler& events) override; + void process(const InputView& events) override; void draw(Canvas& canvas) override; void open(Widget* parent, const RectF& at);