Started work on grid creation menu
This commit is contained in:
parent
57392cdfdd
commit
55b15e0ec1
18 changed files with 533 additions and 181 deletions
|
|
@ -14,6 +14,7 @@ struct DrawData {
|
|||
MainProgram* main;
|
||||
bool dontUseDrawProgCache = false;
|
||||
bool clampDrawBetween = true;
|
||||
bool drawGrids = true;
|
||||
WorldScalar clampDrawMinimum;
|
||||
WorldScalar clampDrawMaximum;
|
||||
WorldScalar mipMapLevelOne;
|
||||
|
|
|
|||
|
|
@ -203,6 +203,15 @@ void ScreenshotTool::take_screenshot(const std::filesystem::path& filePath) {
|
|||
SkRect canvasBounds = SkRect::MakeLTRB(0.0f, 0.0f, canvasSize.x(), canvasSize.y());
|
||||
std::unique_ptr<SkCanvas> canvas = SkSVGCanvas::Make(canvasBounds, &out, SkSVGCanvas::kConvertTextToPaths_Flag | SkSVGCanvas::kNoPrettyXML_Flag);
|
||||
take_screenshot_svg(canvas.get(), controls.transparentBackground);
|
||||
canvas = nullptr; // Ensure that SVG is completely written into the stream
|
||||
#ifdef __EMSCRIPTEN__
|
||||
auto skData = out.detachAsData();
|
||||
emscripten_browser_file::download(
|
||||
"screenshot.svg",
|
||||
"image/svg+xml",
|
||||
std::string_view((const char*)skData->bytes(), skData->size())
|
||||
);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,16 +241,16 @@ void ScreenshotTool::take_screenshot_svg(SkCanvas* canvas, bool transparentBackg
|
|||
drawP.world.drawData.cam.set_viewing_area(canvasSize);
|
||||
drawP.world.drawData.refresh_draw_optimizing_values();
|
||||
|
||||
GridManager::GridType oldGridType = drawP.world.main.grid.gridType;
|
||||
bool oldDrawGrids = drawP.world.drawData.drawGrids;
|
||||
drawP.world.main.takingScreenshot = true;
|
||||
drawP.world.main.transparentBackground = transparentBackground;
|
||||
drawP.world.main.grid.gridType = GridManager::GRIDTYPE_NONE;
|
||||
drawP.world.drawData.drawGrids = false;
|
||||
drawP.world.drawData.dontUseDrawProgCache = true;
|
||||
|
||||
drawP.world.main.draw(canvas);
|
||||
drawP.world.main.takingScreenshot = false;
|
||||
drawP.world.main.transparentBackground = false;
|
||||
drawP.world.main.grid.gridType = oldGridType;
|
||||
drawP.world.drawData.drawGrids = oldDrawGrids;
|
||||
drawP.world.drawData = d;
|
||||
}
|
||||
|
||||
|
|
@ -271,17 +280,16 @@ void ScreenshotTool::take_screenshot_area_hw(const sk_sp<SkSurface>& surface, Sk
|
|||
drawP.world.drawData.cam.set_viewing_area(sectionImageSize.cast<float>());
|
||||
drawP.world.drawData.refresh_draw_optimizing_values();
|
||||
|
||||
GridManager::GridType oldGridType = drawP.world.main.grid.gridType;
|
||||
bool oldDrawGrids = drawP.world.drawData.drawGrids;
|
||||
drawP.world.main.takingScreenshot = true;
|
||||
drawP.world.main.transparentBackground = transparentBackground;
|
||||
if(!controls.displayGrid)
|
||||
drawP.world.main.grid.gridType = GridManager::GRIDTYPE_NONE;
|
||||
drawP.world.drawData.drawGrids = controls.displayGrid;
|
||||
drawP.world.drawData.dontUseDrawProgCache = true;
|
||||
|
||||
drawP.world.main.draw(canvas);
|
||||
drawP.world.main.takingScreenshot = false;
|
||||
drawP.world.main.transparentBackground = false;
|
||||
drawP.world.main.grid.gridType = oldGridType;
|
||||
drawP.world.drawData.drawGrids = oldDrawGrids;
|
||||
drawP.world.drawData = d;
|
||||
|
||||
SkImageInfo aaImgInfo = SkImageInfo::Make(sectionImageSize.x(), sectionImageSize.y(), kRGBA_8888_SkColorType, kPremul_SkAlphaType);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace GUIStuff {
|
|||
|
||||
template <typename T> class TextBox : public Element {
|
||||
public:
|
||||
void update(UpdateInputData& io, T* newData, const std::function<std::optional<T>(const std::string&)>& newFromStr, const std::function<std::string(const T&)> newToStr, bool newSingleLine, const std::function<void()>& elemUpdate) {
|
||||
void update(UpdateInputData& io, T* newData, const std::function<std::optional<T>(const std::string&)>& newFromStr, const std::function<std::string(const T&)> newToStr, bool newSingleLine, bool updateEveryEdit, const std::function<void()>& elemUpdate) {
|
||||
data = newData;
|
||||
fromStr = newFromStr;
|
||||
toStr = newToStr;
|
||||
|
|
@ -18,7 +18,7 @@ template <typename T> class TextBox : public Element {
|
|||
|
||||
CLAY({
|
||||
.layout = {
|
||||
.sizing = {.width = CLAY_SIZING_GROW(static_cast<float>(io.fontSize * 2)), .height = CLAY_SIZING_GROW(0)}
|
||||
.sizing = {.width = CLAY_SIZING_GROW(static_cast<float>(io.fontSize * 2)), .height = CLAY_SIZING_GROW(static_cast<float>(io.fontSize))}
|
||||
},
|
||||
.custom = { .customData = this }
|
||||
}) {
|
||||
|
|
@ -59,12 +59,14 @@ template <typename T> class TextBox : public Element {
|
|||
cur.selectionEndPos = cur.selectionBeginPos = cur.pos = textbox.remove(cur.selectionBeginPos, cur.selectionEndPos);
|
||||
else
|
||||
cur.selectionEndPos = cur.selectionBeginPos = cur.pos = textbox.remove(cur.pos, textbox.move(CollabTextBox::Movement::kLeft, cur.pos));
|
||||
update_on_edit(updateEveryEdit);
|
||||
}
|
||||
if(io.key.del) {
|
||||
if(cur.selectionBeginPos != cur.selectionEndPos)
|
||||
cur.selectionEndPos = cur.selectionBeginPos = cur.pos = textbox.remove(cur.selectionBeginPos, cur.selectionEndPos);
|
||||
else
|
||||
cur.selectionEndPos = cur.selectionBeginPos = cur.pos = textbox.remove(cur.pos, textbox.move(CollabTextBox::Movement::kRight, cur.pos));
|
||||
update_on_edit(updateEveryEdit);
|
||||
}
|
||||
if(io.key.paste) {
|
||||
if(cur.selectionBeginPos != cur.selectionEndPos)
|
||||
|
|
@ -76,6 +78,7 @@ template <typename T> class TextBox : public Element {
|
|||
}
|
||||
else
|
||||
cur.selectionEndPos = cur.selectionBeginPos = cur.pos = textbox.insert(cur.pos, io.clipboard.textInFunc());
|
||||
update_on_edit(updateEveryEdit);
|
||||
}
|
||||
if(io.key.enter) {
|
||||
if(singleLine) {
|
||||
|
|
@ -91,6 +94,7 @@ template <typename T> class TextBox : public Element {
|
|||
cur.pos.fParagraphIndex++;
|
||||
cur.pos.fTextByteIndex = 0;
|
||||
cur.selectionBeginPos = cur.selectionEndPos = cur.pos;
|
||||
update_on_edit(updateEveryEdit);
|
||||
}
|
||||
}
|
||||
if(io.key.copy) {
|
||||
|
|
@ -100,6 +104,7 @@ template <typename T> class TextBox : public Element {
|
|||
io.clipboard.textOut = textbox.copy(cur.selectionBeginPos, cur.selectionEndPos);
|
||||
if(cur.selectionBeginPos != cur.selectionEndPos)
|
||||
cur.selectionEndPos = cur.selectionBeginPos = cur.pos = textbox.remove(cur.selectionBeginPos, cur.selectionEndPos);
|
||||
update_on_edit(updateEveryEdit);
|
||||
}
|
||||
if(io.key.selectAll) {
|
||||
cur.selectionEndPos.fParagraphIndex = textbox.lineCount() == 0 ? 0 : textbox.lineCount() - 1;
|
||||
|
|
@ -112,6 +117,7 @@ template <typename T> class TextBox : public Element {
|
|||
if(cur.selectionBeginPos != cur.selectionEndPos)
|
||||
cur.selectionEndPos = cur.selectionBeginPos = cur.pos = textbox.remove(cur.selectionBeginPos, cur.selectionEndPos);
|
||||
cur.selectionEndPos = cur.selectionBeginPos = cur.pos = textbox.insert(cur.pos, io.textInput);
|
||||
update_on_edit(updateEveryEdit);
|
||||
}
|
||||
|
||||
io.acceptingTextInput = true;
|
||||
|
|
@ -178,6 +184,16 @@ template <typename T> class TextBox : public Element {
|
|||
//oldData = T();
|
||||
}
|
||||
|
||||
void update_on_edit(bool updateEveryEdit) {
|
||||
if(updateEveryEdit) {
|
||||
std::optional<T> dataToAssign = fromStr(textbox.get_string());
|
||||
if(dataToAssign) {
|
||||
*data = dataToAssign.value();
|
||||
oldData = *data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
T* data = nullptr;
|
||||
T oldData;
|
||||
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@ void GUIManager::input_color_component_255(const std::string& id, float* val, co
|
|||
std::stringstream ss;
|
||||
ss << convertTo255;
|
||||
return ss.str();
|
||||
}, true, elemUpdate);
|
||||
}, true, false, elemUpdate);
|
||||
pop_id();
|
||||
}
|
||||
|
||||
|
|
@ -329,7 +329,7 @@ void GUIManager::input_text(const std::string& id, std::string* val, const std::
|
|||
},
|
||||
[&](const std::string& str) {
|
||||
return str;
|
||||
}, true, elemUpdate);
|
||||
}, true, true, elemUpdate);
|
||||
pop_id();
|
||||
}
|
||||
|
||||
|
|
@ -414,7 +414,7 @@ void GUIManager::input_scalar(const std::string& id, uint8_t* val, uint8_t min,
|
|||
},
|
||||
[&](const uint8_t& a) {
|
||||
return std::to_string(static_cast<uint32_t>(a));
|
||||
}, true, elemUpdate);
|
||||
}, true, false, elemUpdate);
|
||||
pop_id();
|
||||
}
|
||||
|
||||
|
|
@ -429,7 +429,7 @@ void GUIManager::input_path(const std::string& id, std::filesystem::path* val, s
|
|||
},
|
||||
[&](const std::filesystem::path& a) {
|
||||
return a.string();
|
||||
}, true, elemUpdate);
|
||||
}, true, false, elemUpdate);
|
||||
pop_id();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -107,9 +107,9 @@ class GUIManager {
|
|||
return toRet;
|
||||
}
|
||||
|
||||
template <typename T> void input_generic(const std::string& id, T* val, const std::function<std::optional<T>(const std::string&)>& fromStr, const std::function<std::string(const T&)>& toStr, bool singleLine, const std::function<void()>& elemUpdate = nullptr) {
|
||||
template <typename T> void input_generic(const std::string& id, T* val, const std::function<std::optional<T>(const std::string&)>& fromStr, const std::function<std::string(const T&)>& toStr, bool singleLine, bool updateEveryEdit, const std::function<void()>& elemUpdate = nullptr) {
|
||||
push_id(id);
|
||||
insert_element<TextBox<T>>()->update(*io, val, fromStr, toStr, singleLine, elemUpdate);
|
||||
insert_element<TextBox<T>>()->update(*io, val, fromStr, toStr, singleLine, updateEveryEdit, elemUpdate);
|
||||
pop_id();
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ class GUIManager {
|
|||
else
|
||||
ss << *val;
|
||||
return ss.str();
|
||||
}, true, elemUpdate);
|
||||
}, true, false, elemUpdate);
|
||||
}
|
||||
|
||||
void input_scalar(const std::string& id, uint8_t* val, uint8_t min, uint8_t max, int decimalPrecision, const std::function<void()>& elemUpdate);
|
||||
|
|
@ -184,7 +184,7 @@ class GUIManager {
|
|||
}
|
||||
return ss.str();
|
||||
},
|
||||
true, elemUpdate);
|
||||
true, false, elemUpdate);
|
||||
}
|
||||
|
||||
template <typename T> void color_picker(const std::string& id, T* val, bool selectAlpha, const std::function<void()>& elemUpdate = nullptr) {
|
||||
|
|
|
|||
|
|
@ -1,118 +1,34 @@
|
|||
#include "GridManager.hpp"
|
||||
#include "MainProgram.hpp"
|
||||
#include <include/core/SkPaint.h>
|
||||
#include <include/core/SkPathTypes.h>
|
||||
#include <Helpers/MathExtras.hpp>
|
||||
|
||||
#define GRID_CACHE_SIZE 10.0f
|
||||
#define GRID_CACHE_SIZE_INT 10
|
||||
#define GRID_DOT_SIZE 0.5f
|
||||
GridManager::GridManager(World& w):
|
||||
world(w) {}
|
||||
|
||||
// NOTE: Skia shaders return premultiplied alpha colors
|
||||
|
||||
const char* squareDotGridSkSl =
|
||||
R"V(
|
||||
uniform vec4 pointColor;
|
||||
|
||||
vec4 main(float2 fragCoord) {
|
||||
float m1 = step(mod(fragCoord.x + 0.25, 10.0), 0.5);
|
||||
float m2 = step(mod(fragCoord.y + 0.25, 10.0), 0.5);
|
||||
float fin = m1 * m2;
|
||||
|
||||
vec4 pointColorUnmultiplied = vec4(pointColor.rgb, 1.0);
|
||||
|
||||
return pointColorUnmultiplied * pointColor.a * fin;
|
||||
})V";
|
||||
|
||||
const char* circleDotGridSkSl =
|
||||
R"V(
|
||||
uniform vec4 pointColor;
|
||||
|
||||
vec4 main(float2 fragCoord) {
|
||||
float2 modCoord = mod(fragCoord + float2(5.0), float2(10.0));
|
||||
float fin = step(distance(float2(5.0), modCoord), 0.25);
|
||||
|
||||
vec4 pointColorUnmultiplied = vec4(pointColor.rgb, 1.0);
|
||||
|
||||
return pointColorUnmultiplied * pointColor.a * fin;
|
||||
})V";
|
||||
|
||||
GridManager::GridManager(MainProgram& initMain):
|
||||
main(initMain)
|
||||
{
|
||||
auto [effect, err] = SkRuntimeEffect::MakeForShader(SkString(circleDotGridSkSl));
|
||||
if(!err.isEmpty()) {
|
||||
std::cout << "[GridManager::GridManager] Circle dot shader construction error\n";
|
||||
std::cout << err.c_str() << std::endl;
|
||||
}
|
||||
circleDotEffect = effect;
|
||||
|
||||
auto [effect2, err2] = SkRuntimeEffect::MakeForShader(SkString(squareDotGridSkSl));
|
||||
if(!err2.isEmpty()) {
|
||||
std::cout << "[GridManager::GridManager] Square dot shader construction error\n";
|
||||
std::cout << err2.c_str() << std::endl;
|
||||
}
|
||||
squareDotEffect = effect2;
|
||||
void GridManager::init_client_callbacks() {
|
||||
}
|
||||
|
||||
sk_sp<SkShader> GridManager::get_shader(const sk_sp<SkRuntimeEffect>& runtimeEffect, const SkColor4f& pointColor) {
|
||||
SkRuntimeShaderBuilder builder(runtimeEffect);
|
||||
builder.uniform("pointColor") = pointColor;
|
||||
sk_sp<SkShader> s = builder.makeShader();
|
||||
return s;
|
||||
void GridManager::add_grid(const std::string& name) {
|
||||
WorldGrid g;
|
||||
grids[name] = g;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
void GridManager::remove_grid(const std::string& name) {
|
||||
grids.erase(name);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const std::vector<std::string>& GridManager::sorted_names() {
|
||||
if(changed) {
|
||||
sortedNames.clear();
|
||||
for(auto& [k, v] : grids)
|
||||
sortedNames.emplace_back(k);
|
||||
std::sort(sortedNames.begin(), sortedNames.end());
|
||||
changed = false;
|
||||
}
|
||||
return sortedNames;
|
||||
}
|
||||
|
||||
void GridManager::draw(SkCanvas* canvas, const DrawData& drawData) {
|
||||
if(gridType == GRIDTYPE_NONE)
|
||||
return;
|
||||
if(oldWindowSize != main.window.size.cast<float>()) {
|
||||
gridPath = SkPath();
|
||||
float maxDim = std::max(main.window.size.x(), main.window.size.y()) * 2.0f;
|
||||
for(float i = 0.0f; i < maxDim; i += GRID_CACHE_SIZE) {
|
||||
gridPath.moveTo(i, -maxDim);
|
||||
gridPath.lineTo(i, maxDim);
|
||||
gridPath.moveTo(-maxDim, i);
|
||||
gridPath.lineTo(maxDim, i);
|
||||
}
|
||||
for(float i = 0.0f; i > -maxDim; i -= GRID_CACHE_SIZE) {
|
||||
gridPath.moveTo(i, -maxDim);
|
||||
gridPath.lineTo(i, maxDim);
|
||||
gridPath.moveTo(-maxDim, i);
|
||||
gridPath.lineTo(maxDim, i);
|
||||
}
|
||||
oldWindowSize = main.window.size.cast<float>();
|
||||
}
|
||||
float alteredZoom = static_cast<float>(((size / WorldScalar(GRID_CACHE_SIZE_INT)) / drawData.cam.c.inverseScale));
|
||||
if(alteredZoom > 0.5f) {
|
||||
Vector2f fracCamPos;
|
||||
|
||||
fracCamPos.x() = static_cast<float>((drawData.cam.c.pos.x() % size) / drawData.cam.c.inverseScale);
|
||||
fracCamPos.y() = static_cast<float>((drawData.cam.c.pos.y() % size) / drawData.cam.c.inverseScale);
|
||||
|
||||
SkPaint linePaint;
|
||||
if(gridType == GRIDTYPE_CIRCLEDOT || gridType == GRIDTYPE_SQUAREDOT) {
|
||||
SkColor4f pointColor = main.world->canvasTheme.toolFrontColor;
|
||||
pointColor.fA = std::clamp(alteredZoom / 20.0f, 0.0f, 0.4f);
|
||||
linePaint.setShader(get_shader(gridType == 1 ? circleDotEffect : squareDotEffect, pointColor));
|
||||
}
|
||||
else if(gridType == GRIDTYPE_LINE) {
|
||||
linePaint.setColor4f(main.world->canvasTheme.toolFrontColor);
|
||||
linePaint.setStyle(SkPaint::kStroke_Style);
|
||||
linePaint.setStrokeWidth(0.0f);
|
||||
linePaint.setAlphaf(std::clamp(alteredZoom / 35.0f, 0.0f, 0.3f));
|
||||
}
|
||||
|
||||
canvas->save();
|
||||
|
||||
canvas->rotate(-drawData.cam.c.rotation * 180.0 / std::numbers::pi);
|
||||
canvas->translate(-fracCamPos.x(), -fracCamPos.y());
|
||||
canvas->scale(alteredZoom, alteredZoom);
|
||||
|
||||
if(gridType == GRIDTYPE_CIRCLEDOT || gridType == GRIDTYPE_SQUAREDOT)
|
||||
canvas->drawPaint(linePaint);
|
||||
else if(gridType == GRIDTYPE_LINE)
|
||||
canvas->drawPath(gridPath, linePaint);
|
||||
|
||||
canvas->restore();
|
||||
}
|
||||
for(auto& [str, g] : grids)
|
||||
g.draw(*this, canvas, drawData);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,20 @@
|
|||
#pragma once
|
||||
#include <Eigen/Dense>
|
||||
#include <include/core/SkCanvas.h>
|
||||
#include "DrawData.hpp"
|
||||
#include <include/core/SkPath.h>
|
||||
#include <include/effects/SkRuntimeEffect.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include "WorldGrid.hpp"
|
||||
|
||||
using namespace Eigen;
|
||||
|
||||
class MainProgram;
|
||||
class World;
|
||||
|
||||
class GridManager {
|
||||
public:
|
||||
GridManager(MainProgram& initMain);
|
||||
GridManager(World& w);
|
||||
void init_client_callbacks();
|
||||
void add_grid(const std::string& name);
|
||||
void remove_grid(const std::string& name);
|
||||
void draw(SkCanvas* canvas, const DrawData& drawData);
|
||||
const std::vector<std::string>& sorted_names();
|
||||
|
||||
enum GridType : unsigned {
|
||||
GRIDTYPE_NONE = 0,
|
||||
GRIDTYPE_CIRCLEDOT,
|
||||
GRIDTYPE_SQUAREDOT,
|
||||
GRIDTYPE_LINE
|
||||
};
|
||||
|
||||
GridType gridType = GRIDTYPE_CIRCLEDOT;
|
||||
std::unordered_map<std::string, WorldGrid> grids;
|
||||
World& world;
|
||||
private:
|
||||
sk_sp<SkShader> get_shader(const sk_sp<SkRuntimeEffect>& runtimeEffect, const SkColor4f& pointColor);
|
||||
Vector2f oldWindowSize{0.0f, 0.0f};
|
||||
WorldScalar size{50000000};
|
||||
SkPath gridPath;
|
||||
sk_sp<SkRuntimeEffect> circleDotEffect;
|
||||
sk_sp<SkRuntimeEffect> squareDotEffect;
|
||||
MainProgram& main;
|
||||
bool changed = true;
|
||||
std::vector<std::string> sortedNames;
|
||||
};
|
||||
|
||||
NLOHMANN_JSON_SERIALIZE_ENUM(GridManager::GridType, {
|
||||
{GridManager::GRIDTYPE_NONE, "None"},
|
||||
{GridManager::GRIDTYPE_CIRCLEDOT, "Circle Dot"},
|
||||
{GridManager::GRIDTYPE_SQUAREDOT, "Square Dot"},
|
||||
{GridManager::GRIDTYPE_LINE, "Line"}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -36,8 +36,7 @@
|
|||
#include <Helpers/Networking/NetLibrary.hpp>
|
||||
|
||||
MainProgram::MainProgram():
|
||||
toolbar(*this),
|
||||
grid(*this)
|
||||
toolbar(*this)
|
||||
{
|
||||
displayName = Random::get().alphanumeric_str(10);
|
||||
|
||||
|
|
@ -229,7 +228,6 @@ void MainProgram::load_config() {
|
|||
|
||||
void MainProgram::draw(SkCanvas* canvas) {
|
||||
canvas->clear(transparentBackground ? SkColor4f{0.0f, 0.0f, 0.0f, 0.0f} : world->canvasTheme.backColor);
|
||||
grid.draw(canvas, world->drawData);
|
||||
world->draw(canvas);
|
||||
toolbar.draw(canvas);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#pragma once
|
||||
#include "DrawData.hpp"
|
||||
#include "GridManager.hpp"
|
||||
#include <chrono>
|
||||
#include <include/core/SkCanvas.h>
|
||||
#include "InputManager.hpp"
|
||||
|
|
@ -66,7 +65,6 @@ class MainProgram {
|
|||
FontData fonts;
|
||||
TimePoint deltaTime;
|
||||
Toolbar toolbar;
|
||||
GridManager grid;
|
||||
std::shared_ptr<World> world;
|
||||
|
||||
std::ofstream* logFile;
|
||||
|
|
|
|||
111
src/Toolbar.cpp
111
src/Toolbar.cpp
|
|
@ -4,7 +4,6 @@
|
|||
#include "MainProgram.hpp"
|
||||
#include "GUIStuff/Elements/Element.hpp"
|
||||
#include "GUIStuff/GUIManager.hpp"
|
||||
#include "GridManager.hpp"
|
||||
#include "InputManager.hpp"
|
||||
#include "World.hpp"
|
||||
#include <SDL3/SDL_dialog.h>
|
||||
|
|
@ -212,7 +211,6 @@ nlohmann::json Toolbar::get_config_json() {
|
|||
toRet["jumpTransitionTime"] = jumpTransitionTime;
|
||||
toRet["dragZoomSpeed"] = dragZoomSpeed;
|
||||
toRet["scrollZoomSpeed"] = scrollZoomSpeed;
|
||||
toRet["gridType"] = main.grid.gridType;
|
||||
toRet["guiFontSize"] = io->fontSize;
|
||||
toRet["showPerformance"] = showPerformance;
|
||||
toRet["displayName"] = main.displayName;
|
||||
|
|
@ -263,7 +261,6 @@ void Toolbar::set_config_json(const nlohmann::json& j) {
|
|||
j.at("dragZoomSpeed").get_to(dragZoomSpeed);
|
||||
j.at("scrollZoomSpeed").get_to(scrollZoomSpeed);
|
||||
j.at("guiScale").get_to(guiScale);
|
||||
j.at("gridType").get_to(main.grid.gridType);
|
||||
j.at("guiFontSize").get_to(io->fontSize);
|
||||
j.at("showPerformance").get_to(showPerformance);
|
||||
j.at("useNativeFilePicker").get_to(useNativeFilePicker);
|
||||
|
|
@ -390,6 +387,7 @@ void Toolbar::top_toolbar() {
|
|||
global_log();
|
||||
bool menuPopUpJustOpen = false;
|
||||
bool bookmarkMenuPopUpJustOpen = false;
|
||||
bool gridMenuPopUpJustOpen = false;
|
||||
if(gui.svg_icon_button("Main Menu Button", "data/icons/menu.svg", menuPopUpOpen)) {
|
||||
menuPopUpOpen = true;
|
||||
menuPopUpJustOpen = true;
|
||||
|
|
@ -401,6 +399,14 @@ void Toolbar::top_toolbar() {
|
|||
gui.tab_list("file tab list", tabNames, main.worldIndex, closedTab);
|
||||
if(main.world->network_being_used() && gui.svg_icon_button("Player List Toggle Button", "data/icons/list.svg", playerMenuOpen))
|
||||
playerMenuOpen = !playerMenuOpen;
|
||||
if(gui.svg_icon_button("Grids Button", "data/icons/grid.svg", gridMenu.popupOpen)) {
|
||||
if(gridMenu.popupOpen)
|
||||
gridMenu.popupOpen = false;
|
||||
else {
|
||||
gridMenu.popupOpen = true;
|
||||
gridMenuPopUpJustOpen = true;
|
||||
}
|
||||
}
|
||||
if(gui.svg_icon_button("Menu Undo Button", "data/icons/undo.svg"))
|
||||
main.world->undo_with_checks();
|
||||
if(gui.svg_icon_button("Menu Redo Button", "data/icons/redo.svg"))
|
||||
|
|
@ -417,6 +423,8 @@ void Toolbar::top_toolbar() {
|
|||
|
||||
if(closedTab)
|
||||
main.set_tab_to_close(closedTab.value());
|
||||
if(gridMenu.popupOpen)
|
||||
grid_menu(gridMenuPopUpJustOpen);
|
||||
if(bookMenu.popupOpen)
|
||||
bookmark_menu(bookmarkMenuPopUpJustOpen);
|
||||
if(menuPopUpOpen) {
|
||||
|
|
@ -532,6 +540,87 @@ If you like this app, consider downloading the native version for your system)")
|
|||
}
|
||||
}
|
||||
|
||||
void Toolbar::grid_menu(bool justOpened) {
|
||||
gui.push_id("grid menu");
|
||||
CLAY({
|
||||
.layout = {
|
||||
.sizing = {.width = CLAY_SIZING_FIT(300), .height = CLAY_SIZING_FIT(0, 600) },
|
||||
.padding = CLAY_PADDING_ALL(io->theme->padding1),
|
||||
.childGap = io->theme->childGap1,
|
||||
.childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_TOP},
|
||||
.layoutDirection = CLAY_TOP_TO_BOTTOM
|
||||
},
|
||||
.backgroundColor = convert_vec4<Clay_Color>(io->theme->backColor1),
|
||||
.cornerRadius = CLAY_CORNER_RADIUS(io->theme->windowCorners1),
|
||||
.floating = {.attachPoints = {.element = CLAY_ATTACH_POINT_RIGHT_TOP, .parent = CLAY_ATTACH_POINT_RIGHT_BOTTOM}, .attachTo = CLAY_ATTACH_TO_PARENT},
|
||||
.border = {.color = convert_vec4<Clay_Color>(io->theme->backColor2), .width = CLAY_BORDER_OUTSIDE(io->theme->windowBorders1)}
|
||||
}) {
|
||||
gui.obstructing_window();
|
||||
gui.text_label_centered("Grids");
|
||||
float entryHeight = 25.0f;
|
||||
if(main.world->gridMan.sorted_names().empty())
|
||||
gui.text_label_centered("No grids yet...");
|
||||
gui.scroll_bar_many_entries_area("grid menu entries", entryHeight, main.world->gridMan.sorted_names().size(), [&](size_t i, bool isListHovered) {
|
||||
const std::string& gridName = main.world->gridMan.sorted_names()[i];
|
||||
bool selectedEntry = gridName == gridMenu.currentSelectedGrid;
|
||||
CLAY({
|
||||
.layout = {
|
||||
.sizing = {.width = CLAY_SIZING_GROW(0), .height = CLAY_SIZING_FIXED(entryHeight)},
|
||||
.childGap = 2,
|
||||
.childAlignment = { .x = CLAY_ALIGN_X_LEFT, .y = CLAY_ALIGN_Y_CENTER},
|
||||
.layoutDirection = CLAY_LEFT_TO_RIGHT
|
||||
},
|
||||
.backgroundColor = selectedEntry ? convert_vec4<Clay_Color>(io->theme->backColor1) : convert_vec4<Clay_Color>(io->theme->backColor2)
|
||||
}) {
|
||||
gui.text_label(gridName);
|
||||
if(Clay_Hovered() && io->mouse.leftClick && isListHovered) {
|
||||
gridMenu.currentSelectedGrid = gridName;
|
||||
gridMenu.newName = gridName;
|
||||
}
|
||||
}
|
||||
});
|
||||
bool gridExists = std::find(main.world->gridMan.sorted_names().begin(), main.world->gridMan.sorted_names().end(), gridMenu.newName) != main.world->gridMan.sorted_names().end();
|
||||
gui.left_to_right_line_layout([&]() {
|
||||
gui.input_text("grid text input", &gridMenu.newName);
|
||||
if(gridExists) {
|
||||
if(gui.svg_icon_button("grid remove button", "data/icons/trash.svg", false, 25.0f)) {
|
||||
main.world->gridMan.remove_grid(gridMenu.newName);
|
||||
gridMenu.newName.clear();
|
||||
gridExists = false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(gui.svg_icon_button("grid add button", "data/icons/plus.svg", false, 25.0f) && !gridMenu.newName.empty())
|
||||
main.world->gridMan.add_grid(gridMenu.newName);
|
||||
}
|
||||
});
|
||||
bool dropdownHover = false;
|
||||
if(gridExists) {
|
||||
auto gridFoundIt = main.world->gridMan.grids.find(gridMenu.newName);
|
||||
if(gridFoundIt != main.world->gridMan.grids.end()) {
|
||||
WorldGrid& g = gridFoundIt->second;
|
||||
size_t typeSelected = static_cast<size_t>(g.gridType);
|
||||
std::vector<std::string> listOfGridTypes = {
|
||||
"Circle Points",
|
||||
"Square Points",
|
||||
"Square Lines",
|
||||
"Ruled"
|
||||
};
|
||||
gui.left_to_right_line_layout([&]() {
|
||||
gui.text_label("Type");
|
||||
gui.dropdown_select("filepicker select type", &typeSelected, listOfGridTypes, 200.0f, [&]() {
|
||||
dropdownHover = Clay_Hovered();
|
||||
});
|
||||
});
|
||||
g.gridType = static_cast<WorldGrid::GridType>(typeSelected);
|
||||
}
|
||||
}
|
||||
if(io->mouse.leftClick && !Clay_Hovered() && !justOpened && !dropdownHover)
|
||||
gridMenu.popupOpen = false;
|
||||
}
|
||||
gui.pop_id();
|
||||
}
|
||||
|
||||
void Toolbar::bookmark_menu(bool justOpened) {
|
||||
gui.push_id("bookmark menu");
|
||||
CLAY({
|
||||
|
|
@ -575,19 +664,21 @@ void Toolbar::bookmark_menu(bool justOpened) {
|
|||
}
|
||||
}
|
||||
});
|
||||
bool serverExists = std::find(main.world->bMan.sorted_names().begin(), main.world->bMan.sorted_names().end(), bookMenu.newName) != main.world->bMan.sorted_names().end();
|
||||
bool bookmarkExists = std::find(main.world->bMan.sorted_names().begin(), main.world->bMan.sorted_names().end(), bookMenu.newName) != main.world->bMan.sorted_names().end();
|
||||
gui.left_to_right_line_layout([&]() {
|
||||
gui.input_text("bookmark text input", &bookMenu.newName);
|
||||
if(gui.svg_icon_button("bookmark add button", "data/icons/plus.svg", false, 25.0f) && !bookMenu.newName.empty())
|
||||
main.world->bMan.add_bookmark(bookMenu.newName);
|
||||
if(serverExists) {
|
||||
if(bookmarkExists) {
|
||||
if(gui.svg_icon_button("bookmark jump button", "data/icons/jump.svg", false, 25.0f))
|
||||
main.world->bMan.jump_to_bookmark(bookMenu.newName);
|
||||
if(gui.svg_icon_button("bookmark remove button", "data/icons/close.svg", false, 25.0f)) {
|
||||
if(gui.svg_icon_button("bookmark remove button", "data/icons/trash.svg", false, 25.0f)) {
|
||||
main.world->bMan.remove_bookmark(bookMenu.newName);
|
||||
bookMenu.newName.clear();
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(gui.svg_icon_button("bookmark add button", "data/icons/plus.svg", false, 25.0f) && !bookMenu.newName.empty())
|
||||
main.world->bMan.add_bookmark(bookMenu.newName);
|
||||
}
|
||||
});
|
||||
if(io->mouse.leftClick && !Clay_Hovered() && !justOpened)
|
||||
bookMenu.popupOpen = false;
|
||||
|
|
@ -1173,10 +1264,6 @@ void Toolbar::options_menu() {
|
|||
}
|
||||
case GSETTINGS_APPEARANCE: {
|
||||
gui.push_id("appearance settings");
|
||||
if(gui.radio_button_field("grid type no grid", "No Grid", (main.grid.gridType == GridManager::GRIDTYPE_NONE))) main.grid.gridType = GridManager::GRIDTYPE_NONE;
|
||||
if(gui.radio_button_field("grid type circle grid", "Circle Dot Grid", (main.grid.gridType == GridManager::GRIDTYPE_CIRCLEDOT))) main.grid.gridType = GridManager::GRIDTYPE_CIRCLEDOT;
|
||||
if(gui.radio_button_field("grid type square grid", "Square Dot Grid", (main.grid.gridType == GridManager::GRIDTYPE_SQUAREDOT))) main.grid.gridType = GridManager::GRIDTYPE_SQUAREDOT;
|
||||
if(gui.radio_button_field("grid type line grid", "Line Grid", (main.grid.gridType == GridManager::GRIDTYPE_LINE))) main.grid.gridType = GridManager::GRIDTYPE_LINE;
|
||||
gui.input_scalar_field("GUI Scale", "GUI Scale", &guiScale, 0.5f, 3.0f, 1);
|
||||
gui.input_scalar_field<uint16_t>("GUI Font Size", "GUI Font Size", &io->fontSize, 10, 30);
|
||||
gui.text_label("Note: Changing font size may break UI in some cases.");
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ class Toolbar {
|
|||
void chat_box();
|
||||
void global_log();
|
||||
void top_toolbar();
|
||||
void grid_menu(bool justOpened);
|
||||
void bookmark_menu(bool justOpened);
|
||||
void drawing_program_gui();
|
||||
void options_menu();
|
||||
|
|
@ -168,6 +169,12 @@ class Toolbar {
|
|||
std::string newName;
|
||||
} bookMenu;
|
||||
|
||||
struct GridMenu {
|
||||
bool popupOpen = false;
|
||||
std::string currentSelectedGrid;
|
||||
std::string newName;
|
||||
} gridMenu;
|
||||
|
||||
bool useNativeFilePicker = true;
|
||||
struct FilePicker {
|
||||
bool isOpen = false;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ World::World(MainProgram& initMain, OpenWorldInfo& worldInfo):
|
|||
main(initMain),
|
||||
rMan(*this),
|
||||
drawProg(*this),
|
||||
bMan(*this)
|
||||
bMan(*this),
|
||||
gridMan(*this)
|
||||
{
|
||||
set_canvas_background_color(main.defaultCanvasBackgroundColor);
|
||||
displayName = main.displayName;
|
||||
|
|
@ -360,11 +361,7 @@ WorldScalar World::calculate_zoom_from_uniform_zoom(WorldScalar uniformZoom, Wor
|
|||
return uniformZoom * ((a1 < a2) ? a1 : a2);
|
||||
}
|
||||
|
||||
void World::draw(SkCanvas* canvas) {
|
||||
drawData.refresh_draw_optimizing_values();
|
||||
|
||||
drawProg.draw(canvas, drawData);
|
||||
|
||||
void World::draw_other_player_cursors(SkCanvas* canvas, const DrawData& drawData) {
|
||||
for(auto& [id, data] : clients) {
|
||||
if(drawData.clampDrawMaximum > data.camCoords.inverseScale && drawData.clampDrawMinimum < data.camCoords.inverseScale) {
|
||||
canvas->save();
|
||||
|
|
@ -396,3 +393,11 @@ void World::draw(SkCanvas* canvas) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
void World::draw(SkCanvas* canvas) {
|
||||
drawData.refresh_draw_optimizing_values();
|
||||
if(drawData.drawGrids)
|
||||
gridMan.draw(canvas, drawData);
|
||||
drawProg.draw(canvas, drawData);
|
||||
draw_other_player_cursors(canvas, drawData);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
#include "DrawingProgram/DrawingProgram.hpp"
|
||||
#include "Toolbar.hpp"
|
||||
#include "SharedTypes.hpp"
|
||||
#include "GridManager.hpp"
|
||||
#include <filesystem>
|
||||
|
||||
class MainProgram;
|
||||
|
|
@ -47,6 +48,7 @@ class World {
|
|||
DrawingProgram drawProg;
|
||||
BookmarkManager bMan;
|
||||
ConnectionManager con;
|
||||
GridManager gridMan;
|
||||
|
||||
std::deque<Toolbar::ChatMessage> chatMessages;
|
||||
|
||||
|
|
@ -101,6 +103,8 @@ class World {
|
|||
void init_client_callbacks();
|
||||
void set_name(const std::string& n);
|
||||
|
||||
void draw_other_player_cursors(SkCanvas* canvas, const DrawData& drawData);
|
||||
|
||||
ClientPortionID nextClientID = 0;
|
||||
WorldVec mousePreviousWorldVec = {0, 0};
|
||||
WorldVec mouseWorldMove = {0, 0};
|
||||
|
|
|
|||
137
src/WorldGrid.cpp
Normal file
137
src/WorldGrid.cpp
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#include "WorldGrid.hpp"
|
||||
#include "GridManager.hpp"
|
||||
#include "MainProgram.hpp"
|
||||
|
||||
sk_sp<SkRuntimeEffect> WorldGrid::circlePointEffect;
|
||||
sk_sp<SkRuntimeEffect> WorldGrid::squarePointEffect;
|
||||
sk_sp<SkRuntimeEffect> WorldGrid::squareLinesEffect;
|
||||
sk_sp<SkRuntimeEffect> WorldGrid::ruledEffect;
|
||||
Vector2f WorldGrid::oldWindowSize = Vector2f{0.0f, 0.0f};
|
||||
|
||||
#define GRID_CACHE_SIZE 10.0f
|
||||
#define GRID_CACHE_SIZE_INT 10
|
||||
|
||||
// NOTE: Skia shaders return premultiplied alpha colors
|
||||
|
||||
const char* circlePointShaderCode = R"V(
|
||||
uniform vec4 gridColor;
|
||||
uniform float gridScale;
|
||||
|
||||
vec4 main(float2 fragCoord) {
|
||||
float circleRadius = min(5.0 / gridScale, 0.25);
|
||||
float2 modCoord = mod(fragCoord + float2(5.0), float2(10.0));
|
||||
float fin = step(distance(float2(5.0), modCoord), circleRadius);
|
||||
|
||||
vec4 pointColorUnmultiplied = vec4(gridColor.rgb, 1.0);
|
||||
|
||||
return pointColorUnmultiplied * gridColor.a * fin;
|
||||
})V";
|
||||
|
||||
const char* squarePointShaderCode = R"V(
|
||||
uniform vec4 gridColor;
|
||||
uniform float gridScale;
|
||||
|
||||
vec4 main(float2 fragCoord) {
|
||||
float squareSize = min(10.0 / gridScale, 0.5);
|
||||
float gridUnit = 1.0 / gridScale;
|
||||
float m1 = step(mod(fragCoord.x + gridUnit + squareSize * 0.5, 10.0), squareSize);
|
||||
float m2 = step(mod(fragCoord.y + gridUnit + squareSize * 0.5, 10.0), squareSize);
|
||||
float fin = m1 * m2;
|
||||
|
||||
vec4 pointColorUnmultiplied = vec4(gridColor.rgb, 1.0);
|
||||
|
||||
return pointColorUnmultiplied * gridColor.a * fin;
|
||||
})V";
|
||||
|
||||
const char* squareLinesShaderCode = R"V(
|
||||
uniform vec4 gridColor;
|
||||
uniform float gridScale;
|
||||
|
||||
vec4 main(float2 fragCoord) {
|
||||
float gridUnit = 1.0 / gridScale;
|
||||
float m1 = step(mod(fragCoord.x + gridUnit, 10.0), gridUnit);
|
||||
float m2 = step(mod(fragCoord.y + gridUnit, 10.0), gridUnit);
|
||||
float fin = max(m1, m2);
|
||||
|
||||
vec4 pointColorUnmultiplied = vec4(gridColor.rgb, 1.0);
|
||||
|
||||
return pointColorUnmultiplied * gridColor.a * fin;
|
||||
})V";
|
||||
|
||||
const char* ruledShaderCode = R"V(
|
||||
uniform vec4 gridColor;
|
||||
uniform float gridScale;
|
||||
|
||||
vec4 main(float2 fragCoord) {
|
||||
float gridUnit = 1.0 / gridScale;
|
||||
float fin = step(mod(fragCoord.y + gridUnit, 10.0), gridUnit);
|
||||
|
||||
vec4 pointColorUnmultiplied = vec4(gridColor.rgb, 1.0);
|
||||
|
||||
return pointColorUnmultiplied * gridColor.a * fin;
|
||||
})V";
|
||||
|
||||
sk_sp<SkRuntimeEffect> WorldGrid::compile_effect_shader_init(const char* shaderName, const char* shaderCode) {
|
||||
auto [effect, err] = SkRuntimeEffect::MakeForShader(SkString(shaderCode));
|
||||
if(!err.isEmpty()) {
|
||||
std::cout << "[GridManager::compile_effect_shader_init] " << shaderName << " shader construction error\n";
|
||||
std::cout << err.c_str() << std::endl;
|
||||
throw std::runtime_error("Shader Compile Failure");
|
||||
}
|
||||
return effect;
|
||||
}
|
||||
|
||||
sk_sp<SkShader> WorldGrid::get_shader(GridType gType, const SkColor4f& gridColor, float gridScale) {
|
||||
sk_sp<SkRuntimeEffect> runtimeEffect;
|
||||
switch(gType) {
|
||||
case GridType::CIRCLE_POINTS:
|
||||
runtimeEffect = circlePointEffect;
|
||||
break;
|
||||
case GridType::SQUARE_POINTS:
|
||||
runtimeEffect = squarePointEffect;
|
||||
break;
|
||||
case GridType::SQUARE_LINES:
|
||||
runtimeEffect = squareLinesEffect;
|
||||
break;
|
||||
case GridType::RULED:
|
||||
runtimeEffect = ruledEffect;
|
||||
break;
|
||||
}
|
||||
SkRuntimeShaderBuilder builder(runtimeEffect);
|
||||
builder.uniform("gridColor") = gridColor;
|
||||
builder.uniform("gridScale") = gridScale;
|
||||
sk_sp<SkShader> s = builder.makeShader();
|
||||
return s;
|
||||
}
|
||||
|
||||
void WorldGrid::draw(GridManager& gMan, SkCanvas* canvas, const DrawData& drawData) {
|
||||
if(!circlePointEffect) {
|
||||
circlePointEffect = compile_effect_shader_init("Circle Point", circlePointShaderCode);
|
||||
squarePointEffect = compile_effect_shader_init("Square Point", squarePointShaderCode);
|
||||
squareLinesEffect = compile_effect_shader_init("Square Lines", squareLinesShaderCode);
|
||||
ruledEffect = compile_effect_shader_init("Ruled", ruledShaderCode);
|
||||
}
|
||||
|
||||
float alteredZoom = static_cast<float>(((size / WorldScalar(GRID_CACHE_SIZE_INT)) / drawData.cam.c.inverseScale));
|
||||
if(alteredZoom > 0.5f) {
|
||||
Vector2f fracCamPos;
|
||||
|
||||
fracCamPos.x() = static_cast<float>((drawData.cam.c.pos.x() % size) / drawData.cam.c.inverseScale);
|
||||
fracCamPos.y() = static_cast<float>((drawData.cam.c.pos.y() % size) / drawData.cam.c.inverseScale);
|
||||
|
||||
SkPaint linePaint;
|
||||
SkColor4f pointColor = gMan.world.canvasTheme.toolFrontColor;
|
||||
pointColor.fA = std::clamp(alteredZoom / 20.0f, 0.0f, 0.4f);
|
||||
linePaint.setShader(get_shader(gridType, pointColor, alteredZoom));
|
||||
|
||||
canvas->save();
|
||||
|
||||
canvas->rotate(-drawData.cam.c.rotation * 180.0 / std::numbers::pi);
|
||||
canvas->translate(-fracCamPos.x(), -fracCamPos.y());
|
||||
canvas->scale(alteredZoom, alteredZoom);
|
||||
|
||||
canvas->drawPaint(linePaint);
|
||||
|
||||
canvas->restore();
|
||||
}
|
||||
}
|
||||
26
src/WorldGrid.hpp
Normal file
26
src/WorldGrid.hpp
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#pragma once
|
||||
#include <include/core/SkCanvas.h>
|
||||
#include "DrawData.hpp"
|
||||
#include <include/effects/SkRuntimeEffect.h>
|
||||
|
||||
class GridManager;
|
||||
|
||||
class WorldGrid {
|
||||
public:
|
||||
void draw(GridManager& gMan, SkCanvas* canvas, const DrawData& drawData);
|
||||
enum class GridType : unsigned {
|
||||
CIRCLE_POINTS = 0,
|
||||
SQUARE_POINTS,
|
||||
SQUARE_LINES,
|
||||
RULED
|
||||
} gridType = GridType::CIRCLE_POINTS;
|
||||
WorldScalar size{50000000};
|
||||
private:
|
||||
static sk_sp<SkShader> get_shader(GridType gType, const SkColor4f& gridColor, float gridScale);
|
||||
static sk_sp<SkRuntimeEffect> compile_effect_shader_init(const char* shaderName, const char* shaderCode);
|
||||
static Vector2f oldWindowSize;
|
||||
static sk_sp<SkRuntimeEffect> circlePointEffect;
|
||||
static sk_sp<SkRuntimeEffect> squarePointEffect;
|
||||
static sk_sp<SkRuntimeEffect> squareLinesEffect;
|
||||
static sk_sp<SkRuntimeEffect> ruledEffect;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue