aboutsummaryrefslogtreecommitdiff
path: root/core
diff options
context:
space:
mode:
authorrtk0c <[email protected]>2021-03-27 23:01:07 -0700
committerrtk0c <[email protected]>2021-03-27 23:01:07 -0700
commit442d2d75d71bbc057e667edc301a79fa1cc813be (patch)
treeb5d1e5068a4d481bc6bcd72dca851ac7a85bf7e4 /core
Initial setup
Diffstat (limited to 'core')
-rw-r--r--core/CMakeLists.txt99
-rw-r--r--core/src/Utils/Dialog/Dialog.cpp18
-rw-r--r--core/src/Utils/Dialog/Dialog.hpp52
-rw-r--r--core/src/Utils/Dialog/Dialog_linux.cpp83
-rw-r--r--core/src/Utils/Dialog/Dialog_macos.mm113
-rw-r--r--core/src/Utils/Dialog/Dialog_win32.cpp64
-rw-r--r--core/src/Utils/Dialog/fwd.hpp1
-rw-r--r--core/src/Utils/Enum.hpp82
-rw-r--r--core/src/Utils/I18n.cpp233
-rw-r--r--core/src/Utils/I18n.hpp69
-rw-r--r--core/src/Utils/Sigslot.cpp214
-rw-r--r--core/src/Utils/Sigslot.hpp150
-rw-r--r--core/src/Utils/String.cpp340
-rw-r--r--core/src/Utils/String.hpp84
-rw-r--r--core/src/Utils/fwd.hpp22
15 files changed, 1624 insertions, 0 deletions
diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt
new file mode 100644
index 0000000..f2077a3
--- /dev/null
+++ b/core/CMakeLists.txt
@@ -0,0 +1,99 @@
+project(CpltCore LANGUAGES CXX)
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+function(add_source_group GROUP_NAME GROUP_SOURCES)
+ set(${GROUP_NAME} ${GROUP_SOURCES})
+ set_source_files_properties(${GROUP_SOURCES}
+ PROPERTIES
+ UNITY_GROUP "${GROUP_NAME}"
+ )
+endfunction()
+
+set(ENTRYPOINT_MODULE_SOURCES
+)
+
+add_source_group(UI_MODULE_SOURCES
+)
+
+add_source_group(UTILS_MODULE_SOURCES
+ src/Utils/Enum.hpp
+ src/Utils/I18n.hpp
+ src/Utils/I18n.cpp
+ src/Utils/Sigslot.hpp
+ src/Utils/Sigslot.cpp
+ src/Utils/String.hpp
+ src/Utils/String.cpp
+)
+
+# These files are compiled individually, hence no UNITY_GROUP property
+# This is because the files here may contain non-c++ languages
+set(UTILS_DIALOG_MODULE_SOURCES
+ src/Utils/Dialog/Dialog.hpp
+ src/Utils/Dialog/Dialog.cpp
+)
+if(APPLE)
+ list(APPEND UTILS_DIALOG_MODULE_SOURCES
+ src/Utils/Dialog/Dialog_macos.mm
+ )
+elseif(WIN32)
+ list(APPEND UTILS_DIALOG_MODULE_SOURCES
+ src/Utils/Dialog/Dialog_win32.cpp
+ )
+elseif(LINUX)
+ list(APPEND UTILS_DIALOG_MODULE_SOURCES
+ src/Utils/Dialog/Dialog_linux.cpp
+ )
+endif()
+
+function(add_executable_variant TARGET_NAME)
+ message("CpltCore: generating executable ${TARGET_NAME}")
+
+ add_executable(${TARGET_NAME}
+ ${ENTRYPOINT_MODULE_SOURCES}
+ ${UI_MODULE_SOURCES}
+ ${UTILS_MODULE_SOURCES}
+ ${UTILS_DIALOG_MODULE_SOURCES}
+ )
+ target_include_directories(${TARGET_NAME} PRIVATE
+ ${CMAKE_CURRENT_LIST_DIR}/src
+ ${CMAKE_SOURCE_DIR}/3rdparty/imgui
+ ${CMAKE_SOURCE_DIR}/3rdparty/imnodes
+ )
+ target_link_libraries(${TARGET_NAME} PRIVATE ${CONAN_LIBS})
+ target_compile_definitions(${TARGET_NAME}
+ PRIVATE
+ PLATFORM_WIN32=$<BOOL:${WIN32}>
+ PLATFORM_MACOS=$<BOOL:${APPLE}>
+ PLATFORM_LINUX=$<BOOL:${LINUX}>
+ )
+
+ # No console window when targetting windows
+ if(WIN32)
+ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
+ # Supposedly the flag -mwindows would automatically make the executable use GUI subsystem
+ # But, when subsystem is set to GUI, linker will only search WinMain and wWinMain but not the standard main (it seems like)
+ # so creating GUI executable fails and the linker silently reverts to the default, CUI subsystem
+ target_link_options(${TARGET_NAME} PRIVATE -Wl,-subsystem:windows)
+ target_link_options(${TARGET_NAME} PRIVATE -Wl,-entry:mainCRTStartup)
+ elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
+ target_link_options(${TARGET_NAME} PRIVATE /SUBSYSTEM:windows /ENTRY:mainCRTStartup)
+ endif()
+ endif()
+
+ if(${CMAKE_UNITY_BUILD})
+ message("CpltCore: - using unity build")
+ set_target_properties(${TARGET_NAME}
+ PROPERTIES
+ # UNITY_BUILD property is automatically set when CMAKE_UNITY_BUILD is set
+ UNITY_BUILD_MODE GROUP
+ )
+ else()
+ message("CpltCore: - using regular build")
+ endif()
+endfunction()
+
+add_executable_variant(CpltCore_main)
+target_compile_definitions(CpltCore_main PRIVATE DOCTEST_CONFIG_DISABLE=1)
+
+add_executable_variant(CpltCore_test)
diff --git a/core/src/Utils/Dialog/Dialog.cpp b/core/src/Utils/Dialog/Dialog.cpp
new file mode 100644
index 0000000..c4459c0
--- /dev/null
+++ b/core/src/Utils/Dialog/Dialog.cpp
@@ -0,0 +1,18 @@
+// Adapted from https://github.com/aaronmjacobs/Boxer/blob/master/include/boxer/boxer.h
+#include "Dialog.hpp"
+
+namespace Dialog {
+
+Selection Show(const char* message, const char* title, Style style) {
+ return Show(message, title, style, kDefaultButtons);
+}
+
+Selection Show(const char* message, const char* title, Buttons buttons) {
+ return Show(message, title, kDefaultStyle, buttons);
+}
+
+Selection Show(const char* message, const char* title) {
+ return Show(message, title, kDefaultStyle, kDefaultButtons);
+}
+
+} // namespace Dialog
diff --git a/core/src/Utils/Dialog/Dialog.hpp b/core/src/Utils/Dialog/Dialog.hpp
new file mode 100644
index 0000000..e8989e3
--- /dev/null
+++ b/core/src/Utils/Dialog/Dialog.hpp
@@ -0,0 +1,52 @@
+// Adapted from https://github.com/aaronmjacobs/Boxer/blob/master/include/boxer/boxer.h
+#pragma once
+
+namespace Dialog {
+
+/// Options for styles to apply to a message box.
+enum class Style {
+ Info,
+ Warning,
+ Error,
+ Question,
+};
+
+/// Options for buttons to provide on a message box.
+enum class Buttons {
+ OK,
+ OKCancel,
+ YesNo,
+ Quit,
+};
+
+/// Possible responses from a message box. 'None' signifies that no option was chosen, and 'Error' signifies that an
+/// error was encountered while creating the message box.
+enum class Selection {
+ OK,
+ Cancel,
+ Yes,
+ No,
+ Quit,
+ None,
+ Error,
+};
+
+/// The default style to apply to a message box.
+constexpr Style kDefaultStyle = Style::Info;
+
+/// The default buttons to provide on a message box.
+constexpr Buttons kDefaultButtons = Buttons::OK;
+
+/// Blocking call to create a modal message box with the given message, title, style, and buttons.
+Selection Show(const char* message, const char* title, Style style, Buttons buttons);
+
+/// Convenience function to call show() with the default buttons.
+Selection Show(const char* message, const char* title, Style style);
+
+/// Convenience function to call show() with the default style.
+Selection Show(const char* message, const char* title, Buttons buttons);
+
+/// Convenience function to call show() with the default style and buttons.
+Selection Show(const char* message, const char* title);
+
+} // namespace Dialog
diff --git a/core/src/Utils/Dialog/Dialog_linux.cpp b/core/src/Utils/Dialog/Dialog_linux.cpp
new file mode 100644
index 0000000..11c3cee
--- /dev/null
+++ b/core/src/Utils/Dialog/Dialog_linux.cpp
@@ -0,0 +1,83 @@
+// Adapted from https://github.com/aaronmjacobs/Boxer/blob/master/src/boxer_linux.cpp
+#include "Dialog.hpp"
+
+#include <gtk/gtk.h>
+
+namespace Dialog {
+namespace {
+
+ GtkMessageType GetMessageType(Style style) {
+ switch (style) {
+ case Style::Info:
+ return GTK_MESSAGE_INFO;
+ case Style::Warning:
+ return GTK_MESSAGE_WARNING;
+ case Style::Error:
+ return GTK_MESSAGE_ERROR;
+ case Style::Question:
+ return GTK_MESSAGE_QUESTION;
+ default:
+ return GTK_MESSAGE_INFO;
+ }
+ }
+
+ GtkButtonsType GetButtonsType(Buttons buttons) {
+ switch (buttons) {
+ case Buttons::OK:
+ return GTK_BUTTONS_OK;
+ case Buttons::OKCancel:
+ return GTK_BUTTONS_OK_CANCEL;
+ case Buttons::YesNo:
+ return GTK_BUTTONS_YES_NO;
+ case Buttons::Quit:
+ return GTK_BUTTONS_CLOSE;
+ default:
+ return GTK_BUTTONS_OK;
+ }
+ }
+
+ Selection getSelection(gint response) {
+ switch (response) {
+ case GTK_RESPONSE_OK:
+ return Selection::OK;
+ case GTK_RESPONSE_CANCEL:
+ return Selection::Cancel;
+ case GTK_RESPONSE_YES:
+ return Selection::Yes;
+ case GTK_RESPONSE_NO:
+ return Selection::No;
+ case GTK_RESPONSE_CLOSE:
+ return Selection::Quit;
+ default:
+ return Selection::None;
+ }
+ }
+
+} // namespace
+
+Selection Show(const char* message, const char* title, Style style, Buttons buttons) {
+ if (!gtk_init_check(0, nullptr)) {
+ return Selection::Error;
+ }
+
+ // Create a parent window to stop gtk_dialog_run from complaining
+ GtkWidget* parent = gtk_window_new(GTK_WINDOW_TOPLEVEL);
+
+ GtkWidget* dialog = gtk_message_dialog_new(GTK_WINDOW(parent),
+ GTK_DIALOG_MODAL,
+ GetMessageType(style),
+ GetButtonsType(buttons),
+ "%s",
+ message);
+ gtk_window_set_title(GTK_WINDOW(dialog), title);
+ Selection selection = getSelection(gtk_dialog_run(GTK_DIALOG(dialog)));
+
+ gtk_widget_destroy(GTK_WIDGET(dialog));
+ gtk_widget_destroy(GTK_WIDGET(parent));
+ while (g_main_context_iteration(nullptr, false)) {
+ // Do nothing
+ }
+
+ return selection;
+}
+} // namespace Dialog
diff --git a/core/src/Utils/Dialog/Dialog_macos.mm b/core/src/Utils/Dialog/Dialog_macos.mm
new file mode 100644
index 0000000..c0164a0
--- /dev/null
+++ b/core/src/Utils/Dialog/Dialog_macos.mm
@@ -0,0 +1,113 @@
+// Adapted from https://github.com/aaronmjacobs/Boxer/blob/master/src/boxer_osx.mm
+#include "Dialog.hpp"
+
+#import <Cocoa/Cocoa.h>
+
+namespace Dialog {
+namespace {
+
+ NSString* const kOkStr = @"OK";
+ NSString* const kCancelStr = @"Cancel";
+ NSString* const kYesStr = @"Yes";
+ NSString* const kNoStr = @"No";
+ NSString* const kQuitStr = @"Quit";
+
+ NSAlertStyle GetAlertStyle(Style style) {
+#if MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_12
+ switch (style) {
+ case Style::Info:
+ return NSAlertStyleInformational;
+ case Style::Warning:
+ return NSAlertStyleWarning;
+ case Style::Error:
+ return NSAlertStyleCritical;
+ case Style::Question:
+ return NSAlertStyleWarning;
+ default:
+ return NSAlertStyleInformational;
+ }
+#else
+ switch (style) {
+ case Style::Info:
+ return NSInformationalAlertStyle;
+ case Style::Warning:
+ return NSWarningAlertStyle;
+ case Style::Error:
+ return NSCriticalAlertStyle;
+ case Style::Question:
+ return NSWarningAlertStyle;
+ default:
+ return NSInformationalAlertStyle;
+ }
+#endif
+ }
+
+ void SetButtons(NSAlert* alert, Buttons buttons) {
+ switch (buttons) {
+ case Buttons::OK:
+ [alert addButtonWithTitle:kOkStr];
+ break;
+ case Buttons::OKCancel:
+ [alert addButtonWithTitle:kOkStr];
+ [alert addButtonWithTitle:kCancelStr];
+ break;
+ case Buttons::YesNo:
+ [alert addButtonWithTitle:kYesStr];
+ [alert addButtonWithTitle:kNoStr];
+ break;
+ case Buttons::Quit:
+ [alert addButtonWithTitle:kQuitStr];
+ break;
+ default:
+ [alert addButtonWithTitle:kOkStr];
+ }
+ }
+
+ Selection GetSelection(int index, Buttons buttons) {
+ switch (buttons) {
+ case Buttons::OK:
+ return index == NSAlertFirstButtonReturn ? Selection::OK : Selection::None;
+ case Buttons::OKCancel:
+ if (index == NSAlertFirstButtonReturn) {
+ return Selection::OK;
+ } else if (index == NSAlertSecondButtonReturn) {
+ return Selection::Cancel;
+ } else {
+ return Selection::None;
+ }
+ case Buttons::YesNo:
+ if (index == NSAlertFirstButtonReturn) {
+ return Selection::Yes;
+ } else if (index == NSAlertSecondButtonReturn) {
+ return Selection::No;
+ } else {
+ return Selection::None;
+ }
+ case Buttons::Quit:
+ return index == NSAlertFirstButtonReturn ? Selection::Quit : Selection::None;
+ default:
+ return Selection::None;
+ }
+ }
+
+} // namespace
+
+Selection show(const char* message, const char* title, Style style, Buttons buttons) {
+ NSAlert* alert = [[NSAlert alloc] init];
+
+ [alert setMessageText:[NSString stringWithCString:title encoding:[NSString defaultCStringEncoding]]];
+ [alert setInformativeText:[NSString stringWithCString:message encoding:[NSString defaultCStringEncoding]]];
+
+ [alert setAlertStyle:GetAlertStyle(style)];
+ SetButtons(alert, buttons);
+
+ // Force the alert to appear on top of any other windows
+ [[alert window] setLevel:NSModalPanelWindowLevel];
+
+ Selection selection = GetSelection([alert runModal], buttons);
+ [alert release];
+
+ return selection;
+}
+
+} // namespace Dialog
diff --git a/core/src/Utils/Dialog/Dialog_win32.cpp b/core/src/Utils/Dialog/Dialog_win32.cpp
new file mode 100644
index 0000000..b82f382
--- /dev/null
+++ b/core/src/Utils/Dialog/Dialog_win32.cpp
@@ -0,0 +1,64 @@
+// Adapted from https://github.com/aaronmjacobs/Boxer/blob/master/src/boxer_win.cpp
+#include "Dialog.hpp"
+
+#define WIN32_LEAN_AND_MEAN
+#include <Windows.h>
+
+namespace Dialog {
+namespace {
+
+ UINT GetIcon(Style style) {
+ switch (style) {
+ case Style::Info:
+ return MB_ICONINFORMATION;
+ case Style::Warning:
+ return MB_ICONWARNING;
+ case Style::Error:
+ return MB_ICONERROR;
+ case Style::Question:
+ return MB_ICONQUESTION;
+ default:
+ return MB_ICONINFORMATION;
+ }
+ }
+
+ UINT GetButtons(Buttons buttons) {
+ switch (buttons) {
+ case Buttons::OK:
+ case Buttons::Quit: // There is no 'Quit' button on Windows :(
+ return MB_OK;
+ case Buttons::OKCancel:
+ return MB_OKCANCEL;
+ case Buttons::YesNo:
+ return MB_YESNO;
+ default:
+ return MB_OK;
+ }
+ }
+
+ Selection GetSelection(int response, Buttons buttons) {
+ switch (response) {
+ case IDOK:
+ return buttons == Buttons::Quit ? Selection::Quit : Selection::OK;
+ case IDCANCEL:
+ return Selection::Cancel;
+ case IDYES:
+ return Selection::Yes;
+ case IDNO:
+ return Selection::No;
+ default:
+ return Selection::None;
+ }
+ }
+
+} // namespace
+
+Selection Show(const char* message, const char* title, Style style, Buttons buttons) {
+ UINT flags = MB_TASKMODAL;
+
+ flags |= GetIcon(style);
+ flags |= GetButtons(buttons);
+
+ return GetSelection(MessageBox(nullptr, message, title, flags), buttons);
+}
+} // namespace Dialog
diff --git a/core/src/Utils/Dialog/fwd.hpp b/core/src/Utils/Dialog/fwd.hpp
new file mode 100644
index 0000000..50e9667
--- /dev/null
+++ b/core/src/Utils/Dialog/fwd.hpp
@@ -0,0 +1 @@
+#pragma once
diff --git a/core/src/Utils/Enum.hpp b/core/src/Utils/Enum.hpp
new file mode 100644
index 0000000..5075155
--- /dev/null
+++ b/core/src/Utils/Enum.hpp
@@ -0,0 +1,82 @@
+#pragma once
+
+#include <compare>
+
+template <class TSelf>
+struct BasicEnum {
+ using Self = TSelf;
+ using ValueType = int;
+
+ int value;
+
+ BasicEnum()
+ : value{ 0 } {}
+ BasicEnum(int value)
+ : value{ value } {}
+
+ /// Comparsion between 2 values of enum. Useful for situations like `a == b` where both a and b are TSelf.
+ friend auto operator<=>(const TSelf& a, const TSelf& b) { return a.value <=> b.value; }
+ /// Comparsion between a enum and a raw value. Useful for situations like `a == TSelf::Option` where a is a enum and TSelf::Option is a raw value.
+ friend auto operator<=>(const TSelf& self, int value) { return self.value <=> value; }
+
+ operator int() const { return value; }
+ operator bool() const { return value; }
+};
+
+#define ENUM(Name) struct Name : public BasicEnum<Name>
+#define ENUM_MEMBERS() \
+ enum Enum : int; \
+ operator Enum() const { return (Enum)value; } \
+ using BasicEnum<Self>::BasicEnum; \
+ enum Enum : int
+
+template <class TSelf>
+struct BasicFlag {
+ using Self = TSelf;
+ using ValueType = int;
+
+ int value;
+
+ BasicFlag()
+ : value{ 0 } {}
+ BasicFlag(int value)
+ : value{ value } {}
+
+ bool IsSet(TSelf mask) const { return (value & mask.value) == mask.value; }
+ bool IsSetExclusive(TSelf mask) const { return value == mask.value; }
+ void Set(TSelf mask, bool state) {
+ if (state) {
+ value = (int)(value | mask.value);
+ } else {
+ value = (int)(value & ~mask.value);
+ }
+ }
+
+ /// Comparsion between 2 values of flag. Useful for situations like `a == b` where both a and b are TSelf.
+ friend bool operator==(const TSelf& a, const TSelf& b) { return a.value == b.value; }
+ friend auto operator<=>(const TSelf& a, const TSelf& b) { return a.value <=> b.value; }
+ /// Comparsion between a flag and a raw value. Useful for situations like `a == TSelf::Option` where a is a flag and TSelf::Option is a raw value.
+ friend bool operator==(const TSelf& self, int value) { return self.value == value; }
+ friend auto operator<=>(const TSelf& self, int value) { return self.value <=> value; }
+
+ friend TSelf operator&(const TSelf& a, const TSelf& b) { return TSelf(a.value & b.value); }
+ friend TSelf operator|(const TSelf& a, const TSelf& b) { return TSelf(a.value | b.value); }
+ friend TSelf operator^(const TSelf& a, const TSelf& b) { return TSelf(a.value ^ b.value); }
+
+ TSelf operator~() const { return TSelf(~value); }
+
+ TSelf& operator&=(int that) {
+ value = value & that;
+ return *this;
+ }
+
+ TSelf& operator|=(int that) {
+ value = value | that;
+ return *this;
+ }
+
+ TSelf& operator^=(int that) {
+ value = value ^ that;
+ return *this;
+ }
+};
diff --git a/core/src/Utils/I18n.cpp b/core/src/Utils/I18n.cpp
new file mode 100644
index 0000000..645cfc5
--- /dev/null
+++ b/core/src/Utils/I18n.cpp
@@ -0,0 +1,233 @@
+#include "I18n.hpp"
+
+#include "Utils/String.hpp"
+
+#include <json/reader.h>
+#include <json/value.h>
+#include <tsl/array_map.h>
+#include <filesystem>
+#include <fstream>
+#include <stdexcept>
+#include <string_view>
+#include <utility>
+
+namespace fs = std::filesystem;
+namespace {
+
+struct LanguageInfo {
+ std::string codeName;
+ std::string localeName;
+ fs::path file;
+};
+
+class I18nState {
+public:
+ static I18nState& Get() {
+ static I18nState instance;
+ return instance;
+ }
+
+public:
+ tsl::array_map<char, LanguageInfo> localeInfos;
+ tsl::array_map<char, std::string> currentEntries;
+ LanguageInfo* currentLanguage;
+};
+
+std::string findLocalizedName(const fs::path& localeFile) {
+ std::ifstream ifs{ localeFile };
+ if (!ifs) {
+ // TODO log error
+ throw std::runtime_error("Failed to open locale file.");
+ }
+
+ Json::Value root;
+ ifs >> root;
+ if (auto& name = root["$localized_name"]; name.isString()) {
+ return std::string(name.asCString());
+ } else {
+ // TODO log error
+ throw std::runtime_error("Failed to find $localized_name in language file.");
+ }
+}
+
+} // namespace
+
+void I18n::Init() {
+ auto& state = I18nState::Get();
+
+ auto dir = fs::current_path() / "locale";
+ if (!fs::exists(dir)) {
+ // TODO log error
+ return;
+ }
+
+ for (auto& elm : fs::directory_iterator{ dir }) {
+ if (!elm.is_regular_file()) continue;
+
+ auto& path = elm.path();
+ auto codeName = path.stem().string();
+
+ state.localeInfos.emplace(
+ codeName,
+ LanguageInfo{
+ .codeName = codeName,
+ .localeName = findLocalizedName(path),
+ .file = path,
+ });
+ }
+
+ SetLanguage("en_US");
+}
+
+void I18n::Shutdown() {
+ // Nothing to do yet
+}
+
+Signal<> I18n::reloadSignal{};
+
+void I18n::ReloadLocales() {
+ auto& state = I18nState::Get();
+ reloadSignal();
+}
+
+std::string_view I18n::GetLanguage() {
+ auto& state = I18nState::Get();
+ return state.currentLanguage->localeName;
+}
+
+bool I18n::SetLanguage(std::string_view lang) {
+ auto& state = I18nState::Get();
+ if (!state.currentLanguage) return false;
+ if (state.currentLanguage->codeName == lang) return false;
+
+ if (auto iter = state.localeInfos.find(lang); iter != state.localeInfos.end()) {
+ state.currentLanguage = &iter.value();
+ state.currentEntries.clear();
+
+ auto& file = state.currentLanguage->file;
+ std::ifstream ifs{ file };
+ Json::Value root;
+ ifs >> root;
+
+ for (auto name : root.getMemberNames()) {
+ auto& value = root[name];
+ }
+ }
+ ReloadLocales();
+ return true;
+}
+
+std::optional<std::string_view> I18n::Lookup(std::string_view key) {
+ auto& state = I18nState::Get();
+ auto iter = state.currentEntries.find(key);
+ if (iter != state.currentEntries.end()) {
+ return iter.value();
+ } else {
+ return std::nullopt;
+ }
+}
+
+std::string_view I18n::LookupUnwrap(std::string_view key) {
+ auto o = Lookup(key);
+ if (!o) {
+ std::string msg;
+ msg.append("Unable to find locale for '");
+ msg.append(key);
+ msg.append("'.");
+ throw std::runtime_error(std::move(msg));
+ };
+ return o.value();
+}
+
+BasicTranslation::BasicTranslation(std::string_view key)
+ : mContent{ I18n::LookupUnwrap(key) } {
+}
+
+std::string_view BasicTranslation::Get() const {
+ return mContent;
+}
+
+FormattedTranslation::FormattedTranslation(std::string_view key) {
+ auto src = I18n::LookupUnwrap(key);
+
+ mMinimumResultLen = 0;
+
+ bool escape = false;
+ bool matchingCloseBrace = false;
+ std::string buf;
+ for (char c : src) {
+ switch (c) {
+ case '\\': {
+ // Disallow double (or more) escaping
+ if (escape) throw std::runtime_error("Cannot escape '\\'.");
+
+ escape = true;
+ continue;
+ }
+
+ case '{': {
+ // Escaping an opeing brace cause the whole "argument" (if any) gets parsed as a part of the previous literal
+ if (escape) {
+ buf += '{';
+ break;
+ }
+
+ // Generate literal
+ mMinimumResultLen += buf.size();
+ mParsedElements.push_back(Element{ std::move(buf) }); // Should also clear buf
+
+ matchingCloseBrace = true;
+ } break;
+ case '}': {
+ if (escape) throw std::runtime_error("Cannot escape '}', put \\ before the '{' if intended to escape braces.");
+
+ // If there is no pairing '{', simply treat this as a normal character
+ // (escaping for closing braces)
+ if (!matchingCloseBrace) {
+ buf += '}';
+ break;
+ }
+
+ // Generate argument
+ if (buf.empty()) {
+ // No index given, default to use current argument's index
+ auto currArgIdx = (int)mNumArguments;
+ mParsedElements.push_back(Element{ currArgIdx });
+ } else {
+ // Use provided index
+ int argIdx = std::stoi(buf);
+ mParsedElements.push_back(Element{ argIdx });
+ buf.clear();
+ }
+ } break;
+
+ default: {
+ if (escape) throw std::runtime_error("Cannot escape normal character '" + std::to_string(c) + "'.");
+
+ buf += c;
+ } break;
+ }
+
+ escape = false;
+ }
+}
+
+std::string FormattedTranslation::Format(std::span<Argument> args) {
+ if (args.size() != mNumArguments) {
+ throw std::runtime_error("Invalid number of arguments for FormattedTranslation::Format, expected " + std::to_string(mNumArguments) + " but found " + std::to_string(args.size()) + ".");
+ }
+
+ std::string result;
+ result.reserve(mMinimumResultLen);
+
+ for (auto& elm : mParsedElements) {
+ if (auto literal = std::get_if<std::string>(&elm)) {
+ result.append(*literal);
+ }
+ if (auto idx = std::get_if<int>(&elm)) {
+ result.append(args[*idx]);
+ }
+ }
+
+ return result;
+}
diff --git a/core/src/Utils/I18n.hpp b/core/src/Utils/I18n.hpp
new file mode 100644
index 0000000..6b72d29
--- /dev/null
+++ b/core/src/Utils/I18n.hpp
@@ -0,0 +1,69 @@
+#pragma once
+
+#include "Utils/Sigslot.hpp"
+#include "Utils/fwd.hpp"
+
+#include <cstddef>
+#include <optional>
+#include <span>
+#include <string>
+#include <string_view>
+#include <variant>
+#include <vector>
+
+class I18n {
+public:
+ static void Init();
+ static void Shutdown();
+
+ static Signal<> reloadSignal;
+ static void ReloadLocales();
+
+ static std::string_view GetLanguage();
+ static bool SetLanguage(std::string_view lang);
+
+ static std::optional<std::string_view> Lookup(std::string_view key);
+ static std::string_view LookupUnwrap(std::string_view key);
+ static std::string_view LookupLanguage(std::string_view lang);
+};
+
+struct StringArgument {
+ std::string Value;
+};
+
+struct IntArgument {
+ int Value;
+};
+
+struct FloatArgument {
+ double Value;
+};
+
+class BasicTranslation {
+private:
+ std::string_view mContent;
+
+public:
+ BasicTranslation(std::string_view key);
+ std::string_view Get() const;
+};
+
+class FormattedTranslation {
+public:
+ using Element = std::variant<std::string, int>;
+ using Argument = std::string;
+
+private:
+ std::vector<Element> mParsedElements;
+ size_t mNumArguments;
+ size_t mMinimumResultLen;
+
+public:
+ FormattedTranslation(std::string_view key);
+ std::string Format(std::span<Argument> args);
+};
+
+class NumericTranslation {
+public:
+ // TODO
+};
diff --git a/core/src/Utils/Sigslot.cpp b/core/src/Utils/Sigslot.cpp
new file mode 100644
index 0000000..a36eb2f
--- /dev/null
+++ b/core/src/Utils/Sigslot.cpp
@@ -0,0 +1,214 @@
+#include "Sigslot.hpp"
+
+#include <doctest/doctest.h>
+
+bool SignalStub::Connection::IsOccupied() const {
+ return id != InvalidId;
+}
+
+SignalStub::SignalStub(IWrapper& wrapper)
+ : mWrapper{ &wrapper } {
+}
+
+SignalStub::~SignalStub() {
+ RemoveAllConnections();
+}
+
+std::span<const SignalStub::Connection> SignalStub::GetConnections() const {
+ return mConnections;
+}
+
+SignalStub::Connection& SignalStub::InsertConnection(SlotGuard* guard) {
+ Connection* result;
+ int size = static_cast<int>(mConnections.size());
+ for (int i = 0; i < size; ++i) {
+ auto& conn = mConnections[i];
+ if (!conn.IsOccupied()) {
+ result = &conn;
+ result->id = i;
+ goto setup;
+ }
+ }
+
+ mConnections.push_back(Connection{});
+ result = &mConnections.back();
+ result->id = size;
+
+setup:
+ if (guard) {
+ result->guard = guard;
+ result->slotId = guard->InsertConnection(*this, result->id);
+ }
+ return *result;
+}
+
+void SignalStub::RemoveConnection(int id) {
+ if (id >= 0 && id < mConnections.size()) {
+ auto& conn = mConnections[id];
+ if (conn.IsOccupied()) {
+ mWrapper->RemoveFunction(conn.id);
+ if (conn.guard) {
+ conn.guard->RemoveConnection(conn.slotId);
+ }
+
+ conn.guard = nullptr;
+ conn.slotId = SignalStub::InvalidId;
+ conn.id = SignalStub::InvalidId;
+ }
+ }
+}
+
+void SignalStub::RemoveConnectionFor(SlotGuard& guard) {
+ guard.RemoveConnectionFor(*this);
+}
+
+void SignalStub::RemoveAllConnections() {
+ for (size_t i = 0; i < mConnections.size(); ++i) {
+ RemoveConnection(i);
+ }
+}
+
+SlotGuard::SlotGuard() {
+}
+
+SlotGuard::~SlotGuard() {
+ DisconnectAll();
+}
+
+void SlotGuard::DisconnectAll() {
+ for (auto& conn : mConnections) {
+ if (conn.stub) {
+ // Also calls SlotGuard::removeConnection, our copy of the data will be cleared in it
+ conn.stub->RemoveConnection(conn.stubId);
+ }
+ }
+}
+
+int SlotGuard::InsertConnection(SignalStub& stub, int stubId) {
+ int size = static_cast<int>(mConnections.size());
+ for (int i = 0; i < size; ++i) {
+ auto& conn = mConnections[i];
+ if (!conn.stub) {
+ conn.stub = &stub;
+ conn.stubId = stubId;
+ return i;
+ }
+ }
+
+ mConnections.push_back(Connection{});
+ auto& conn = mConnections.back();
+ conn.stub = &stub;
+ conn.stubId = stubId;
+ return size;
+}
+
+void SlotGuard::RemoveConnectionFor(SignalStub& stub) {
+ for (auto& conn : mConnections) {
+ if (conn.stub == &stub) {
+ conn.stub->RemoveConnection(conn.stubId);
+ }
+ }
+}
+
+void SlotGuard::RemoveConnection(int slotId) {
+ mConnections[slotId] = {};
+}
+
+TEST_CASE("Signal connect and disconnect") {
+ Signal<> sig;
+
+ int counter = 0;
+ int id = sig.Connect([&]() { counter++; });
+
+ sig();
+ CHECK(counter == 1);
+
+ sig();
+ CHECK(counter == 2);
+
+ sig.Disconnect(id);
+ sig();
+ CHECK(counter == 2);
+}
+
+TEST_CASE("Signal with parameters") {
+ Signal<int> sig;
+
+ int counter = 0;
+ int id = sig.Connect([&](int i) { counter += i; });
+
+ sig(1);
+ CHECK(counter == 1);
+
+ sig(0);
+ CHECK(counter == 1);
+
+ sig(4);
+ CHECK(counter == 5);
+
+ sig.Disconnect(id);
+ sig(1);
+ CHECK(counter == 5);
+}
+
+TEST_CASE("Signal disconnectAll()") {
+ Signal<> sig;
+
+ int counter1 = 0;
+ int counter2 = 0;
+ sig.Connect([&]() { counter1++; });
+ sig.Connect([&]() { counter2++; });
+
+ sig();
+ CHECK(counter1 == 1);
+ CHECK(counter2 == 1);
+
+ sig();
+ CHECK(counter1 == 2);
+ CHECK(counter2 == 2);
+
+ sig.DisconnectAll();
+ sig();
+ CHECK(counter1 == 2);
+ CHECK(counter2 == 2);
+}
+
+TEST_CASE("SlotGuard auto-disconnection") {
+ int counter1 = 0;
+ int counter2 = 0;
+ Signal<> sig;
+
+ {
+ SlotGuard guard;
+ sig.Connect(guard, [&]() { counter1 += 1; });
+ sig.Connect(guard, [&]() { counter2 += 1; });
+
+ sig();
+ CHECK(counter1 == 1);
+ CHECK(counter2 == 1);
+
+ sig();
+ CHECK(counter1 == 2);
+ CHECK(counter2 == 2);
+ }
+
+ sig();
+ CHECK(counter1 == 2);
+ CHECK(counter2 == 2);
+}
+
+TEST_CASE("Signal destruct before SlotGuard") {
+ int counter = 0;
+ SlotGuard guard;
+
+ {
+ Signal<> sig2;
+ sig2.Connect(guard, [&]() { counter++; });
+
+ sig2();
+ CHECK(counter == 1);
+ }
+
+ // Shouldn't error
+ guard.DisconnectAll();
+}
diff --git a/core/src/Utils/Sigslot.hpp b/core/src/Utils/Sigslot.hpp
new file mode 100644
index 0000000..9aa5f4b
--- /dev/null
+++ b/core/src/Utils/Sigslot.hpp
@@ -0,0 +1,150 @@
+#pragma once
+
+#include "Utils/fwd.hpp"
+
+#include <cstddef>
+#include <functional>
+#include <span>
+#include <utility>
+#include <vector>
+
+class SignalStub {
+public:
+ /// Non-template interface for Signal<T...> to implement (a barrier to stop template
+ /// arguments propagation).
+ class IWrapper {
+ public:
+ virtual ~IWrapper() = default;
+ virtual void RemoveFunction(int id) = 0;
+ };
+
+ enum {
+ InvalidId = -1,
+ };
+
+ struct Connection {
+ SlotGuard* guard;
+ int slotId;
+ int id = InvalidId; // If `InvalidId`, then this "spot" is unused
+
+ bool IsOccupied() const;
+ };
+
+private:
+ std::vector<Connection> mConnections;
+ IWrapper* mWrapper;
+
+private:
+ template <class...>
+ friend class Signal;
+ friend class SlotGuard;
+
+ SignalStub(IWrapper& wrapper);
+ ~SignalStub();
+
+ SignalStub(const SignalStub&) = delete;
+ SignalStub& operator=(const SignalStub&) = delete;
+ SignalStub(SignalStub&&) = default;
+ SignalStub& operator=(SignalStub&&) = default;
+
+ std::span<const Connection> GetConnections() const;
+ Connection& InsertConnection(SlotGuard* guard = nullptr);
+ void RemoveConnection(int id);
+ void RemoveConnectionFor(SlotGuard& guard);
+ void RemoveAllConnections();
+};
+
+template <class... TArgs>
+class Signal : public SignalStub::IWrapper {
+private:
+ // Must be in this order so that mFunctions is still intact when mStub's destructor runs
+ std::vector<std::function<void(TArgs...)>> mFunctions;
+ SignalStub mStub;
+
+public:
+ Signal()
+ : mStub(*this) {
+ }
+
+ virtual ~Signal() = default;
+
+ Signal(const Signal&) = delete;
+ Signal& operator=(const Signal&) = delete;
+ Signal(Signal&&) = default;
+ Signal& operator=(Signal&&) = default;
+
+ void operator()(TArgs... args) {
+ for (auto& conn : mStub.GetConnections()) {
+ if (conn.IsOccupied()) {
+ mFunctions[conn.id](std::forward<TArgs>(args)...);
+ }
+ }
+ }
+
+ template <class TFunction>
+ int Connect(TFunction slot) {
+ auto& conn = mStub.InsertConnection();
+ mFunctions.resize(std::max(mFunctions.size(), (size_t)conn.id + 1));
+ mFunctions[conn.id] = std::move(slot);
+ return conn.id;
+ }
+
+ template <class TFunction>
+ int Connect(SlotGuard& guard, TFunction slot) {
+ auto& conn = mStub.InsertConnection(&guard);
+ mFunctions.resize(std::max(mFunctions.size(), (size_t)conn.id + 1));
+ mFunctions[conn.id] = std::move(slot);
+ return conn.id;
+ }
+
+ void Disconnect(int id) {
+ mStub.RemoveConnection(id);
+ }
+
+ void DisconnectFor(SlotGuard& guard) {
+ mStub.RemoveConnectionFor(guard);
+ }
+
+ void DisconnectAll() {
+ mStub.RemoveAllConnections();
+ }
+
+ virtual void RemoveFunction(int id) {
+ mFunctions[id] = {};
+ }
+};
+
+/// Automatic disconnection mechanism for Signal<>.
+/// Bind connection to this guard by using the Connect(SlotGuard&, TFunction) overload.
+/// Either DisconnectAll() or the destructor disconnects all connections bound to this guard.
+class SlotGuard {
+private:
+ struct Connection {
+ SignalStub* stub = nullptr;
+ int stubId = SignalStub::InvalidId;
+ };
+ std::vector<Connection> mConnections;
+
+public:
+ friend class SignalStub;
+ SlotGuard();
+ ~SlotGuard();
+
+ SlotGuard(const SlotGuard&) = delete;
+ SlotGuard& operator=(const SlotGuard&) = delete;
+ SlotGuard(SlotGuard&&) = default;
+ SlotGuard& operator=(SlotGuard&&) = default;
+
+ /// Disconnect all connection associated with this SlotGuard.
+ void DisconnectAll();
+
+private:
+ /// \return Slot id.
+ int InsertConnection(SignalStub& stub, int stubId);
+ /// Remove the connection data in this associated with slotId. This does not invoke
+ /// the connections' stub's RemoveConnection function.
+ void RemoveConnection(int slotId);
+ /// Disconnect all connections from the given stub associated with this SlotGuard.
+ /// Implementation for SignalStub::RemoveConnectionsFor(SlotGuard&)
+ void RemoveConnectionFor(SignalStub& stub);
+};
diff --git a/core/src/Utils/String.cpp b/core/src/Utils/String.cpp
new file mode 100644
index 0000000..94cd0f5
--- /dev/null
+++ b/core/src/Utils/String.cpp
@@ -0,0 +1,340 @@
+#include "String.hpp"
+
+#include <doctest/doctest.h>
+
+Utf8Iterator::Utf8Iterator(std::string_view::iterator it)
+ : mIter{ std::move(it) } {
+}
+
+constexpr unsigned char kFirstBitMask = 0b10000000;
+constexpr unsigned char kSecondBitMask = 0b01000000;
+constexpr unsigned char kThirdBitMask = 0b00100000;
+constexpr unsigned char kFourthBitMask = 0b00010000;
+constexpr unsigned char kFifthBitMask = 0b00001000;
+
+Utf8Iterator& Utf8Iterator::operator++() {
+ char firstByte = *mIter;
+ std::string::difference_type offset = 1;
+
+ // This means the first byte has a value greater than 127, and so is beyond the ASCII range.
+ if (firstByte & kFirstBitMask) {
+ // This means that the first byte has a value greater than 224, and so it must be at least a three-octet code point.
+ if (firstByte & kThirdBitMask) {
+ // This means that the first byte has a value greater than 240, and so it must be a four-octet code point.
+ if (firstByte & kFourthBitMask) {
+ offset = 4;
+ } else {
+ offset = 3;
+ }
+ } else {
+ offset = 2;
+ }
+ }
+
+ mIter += offset;
+ mDirty = true;
+ return *this;
+}
+
+Utf8Iterator Utf8Iterator::operator++(int) {
+ Utf8Iterator temp = *this;
+ ++(*this);
+ return temp;
+}
+
+Utf8Iterator& Utf8Iterator::operator--() {
+ --mIter;
+
+ // This means that the previous byte is not an ASCII character.
+ if (*mIter & kFirstBitMask) {
+ --mIter;
+ if ((*mIter & kSecondBitMask) == 0) {
+ --mIter;
+ if ((*mIter & kSecondBitMask) == 0) {
+ --mIter;
+ }
+ }
+ }
+
+ mDirty = true;
+ return *this;
+}
+
+Utf8Iterator Utf8Iterator::operator--(int) {
+ Utf8Iterator temp = *this;
+ --(*this);
+ return temp;
+}
+
+char32_t Utf8Iterator::operator*() const {
+ UpdateCurrentValue();
+ return mCurrentCodePoint;
+}
+
+std::string_view::iterator Utf8Iterator::AsInternal() const {
+ // updateCurrentValue();
+ return mIter;
+}
+
+bool operator==(const Utf8Iterator& lhs, const Utf8Iterator& rhs) {
+ return lhs.mIter == rhs.mIter;
+}
+
+bool operator!=(const Utf8Iterator& lhs, const Utf8Iterator& rhs) {
+ return lhs.mIter != rhs.mIter;
+}
+
+bool operator==(const Utf8Iterator& lhs, std::string_view::iterator rhs) {
+ return lhs.mIter == rhs;
+}
+
+bool operator!=(const Utf8Iterator& lhs, std::string_view::iterator rhs) {
+ return lhs.mIter != rhs;
+}
+
+void Utf8Iterator::UpdateCurrentValue() const {
+ if (!mDirty) {
+ return;
+ }
+
+ mCurrentCodePoint = 0;
+ char firstByte = *mIter;
+
+ // This means the first byte has a value greater than 127, and so is beyond the ASCII range.
+ if (firstByte & kFirstBitMask) {
+ // This means that the first byte has a value greater than 191, and so it must be at least a three-octet code point.
+ if (firstByte & kThirdBitMask) {
+ // This means that the first byte has a value greater than 224, and so it must be a four-octet code point.
+ if (firstByte & kFourthBitMask) {
+ mCurrentCodePoint = (firstByte & 0x07) << 18;
+ char secondByte = *(mIter + 1);
+ mCurrentCodePoint += (secondByte & 0x3f) << 12;
+ char thirdByte = *(mIter + 2);
+ mCurrentCodePoint += (thirdByte & 0x3f) << 6;
+
+ char fourthByte = *(mIter + 3);
+ mCurrentCodePoint += (fourthByte & 0x3f);
+ } else {
+ mCurrentCodePoint = (firstByte & 0x0f) << 12;
+ char secondByte = *(mIter + 1);
+ mCurrentCodePoint += (secondByte & 0x3f) << 6;
+ char thirdByte = *(mIter + 2);
+ mCurrentCodePoint += (thirdByte & 0x3f);
+ }
+ } else {
+ mCurrentCodePoint = (firstByte & 0x1f) << 6;
+ char secondByte = *(mIter + 1);
+ mCurrentCodePoint += (secondByte & 0x3f);
+ }
+ } else {
+ mCurrentCodePoint = firstByte;
+ }
+
+ mDirty = true;
+}
+
+Utf8IterableString::Utf8IterableString(std::string_view str)
+ : mStr{ str } {
+}
+
+Utf8Iterator Utf8IterableString::begin() const {
+ return Utf8Iterator(mStr.begin());
+}
+
+Utf8Iterator Utf8IterableString::end() const {
+ return Utf8Iterator(mStr.end());
+}
+
+TEST_CASE("Iterating ASCII string") {
+ std::string ascii("This is an ASCII string");
+ std::u32string output;
+ output.reserve(ascii.length());
+
+ for (char32_t c : Utf8IterableString(ascii)) {
+ output += c;
+ }
+
+ CHECK(output == U"This is an ASCII string");
+}
+
+// BMP: Basic Multilingual Plane
+TEST_CASE("Iterating BMP string") {
+ std::string unicode("Unicode test \u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5\u7528\u5B57\u7B26\u4E32");
+ std::u32string output;
+ output.reserve(10);
+
+ for (char32_t c : Utf8IterableString(unicode)) {
+ output += c;
+ }
+
+ CHECK(output == U"Unicode test \u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5\u7528\u5B57\u7B26\u4E32");
+}
+
+std::u32string ConvertUtf8To32(std::string_view in) {
+ std::u32string str;
+ // Actual size cannot be smaller than this
+ str.reserve(in.size());
+ for (char32_t codepoint : Utf8IterableString(in)) {
+ str += codepoint;
+ }
+ return str;
+}
+
+std::string ConvertUtf32To8(std::u32string_view in) {
+ std::string str;
+ for (char32_t codepoint : in) {
+ if (codepoint <= 0x7F) {
+ str += codepoint;
+ } else if (codepoint <= 0x7FF) {
+ str += 0xC0 | (codepoint >> 6); // 110xxxxx
+ str += 0x80 | (codepoint & 0x3F); // 10xxxxxx
+ } else if (codepoint <= 0xFFFF) {
+ str += 0xE0 | (codepoint >> 12); // 1110xxxx
+ str += 0x80 | ((codepoint >> 6) & 0x3F); // 10xxxxxx
+ str += 0x80 | (codepoint & 0x3F); // 10xxxxxx
+ } else if (codepoint <= 0x10FFFF) {
+ str += 0xF0 | (codepoint >> 18); // 11110xxx
+ str += 0x80 | ((codepoint >> 12) & 0x3F); // 10xxxxxx
+ str += 0x80 | ((codepoint >> 6) & 0x3F); // 10xxxxxx
+ str += 0x80 | (codepoint & 0x3F); // 10xxxxxx
+ }
+ }
+ return str;
+}
+
+TEST_CASE("convertUtf32To8() with ASCII") {
+ auto output = ConvertUtf32To8(U"This is an ASCII string");
+ CHECK(output == "This is an ASCII string");
+}
+
+TEST_CASE("convertUtf32To8() with BMP codepoints") {
+ auto output = ConvertUtf32To8(U"Unicode test \u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5\u7528\u5B57\u7B26\u4E32");
+ CHECK(output == "Unicode test \u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5\u7528\u5B57\u7B26\u4E32");
+}
+
+std::string_view StringRange(std::string_view str, size_t begin, size_t end) {
+ const char* resBegin;
+ size_t resLength = 0;
+
+ Utf8Iterator it{ str.begin() };
+ size_t i = 0; // Nth codepoint on the string
+
+ // Skip until `it` points to the `begin`-th codepoint in the string
+ while (i < begin) {
+ i++;
+ it++;
+ } // Postcondition: i == begin
+ resBegin = &*it.AsInternal();
+
+ while (i < end) {
+ auto prev = it;
+ i++;
+ it++;
+
+ resLength += std::distance(prev.AsInternal(), it.AsInternal());
+ } // Postcondition: i == end
+
+ return { resBegin, resLength };
+}
+
+TEST_CASE("stringRange() with ASCII") {
+ auto a = StringRange("This is an ASCII string", 1, 1 + 5);
+ std::string range(a);
+ CHECK(range == "his i");
+}
+
+TEST_CASE("stringRange() with BMP codepoints") {
+ std::string range(StringRange("Unicode test \u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5\u7528\u5B57\u7B26\u4E32", 11, 11 + 5));
+ CHECK(range == "t \u8FD9\u662F\u4E00");
+}
+
+size_t StringLength(std::string_view str) {
+ size_t result = 0;
+ for (char32_t _ : Utf8IterableString(str)) {
+ result++;
+ }
+ return result;
+}
+
+TEST_CASE("StringLength() test") {
+ CHECK(StringLength("This is an ASCII string") == 23);
+ CHECK(StringLength("Unicode test \u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5\u7528\u5B57\u7B26\u4E32") == 23);
+}
+
+CodepointInfo StringLastCodepoint(std::string_view str) {
+ Utf8Iterator it{ str.begin() };
+ Utf8Iterator prev{ it };
+ size_t codepoints = 0;
+
+ Utf8Iterator end{ str.end() };
+ while (it != end) {
+ codepoints++;
+
+ prev = it;
+ it++;
+ }
+ // it == end
+ // prev == <last codepoint in str>
+
+ return {
+ codepoints - 1,
+ (size_t)std::distance(str.begin(), prev.AsInternal()),
+ };
+}
+
+TEST_CASE("stringLastCodepoint() ASCII test") {
+ auto [index, byteOffset] = StringLastCodepoint("This is an ASCII string");
+ CHECK(index == 22);
+ CHECK(index == 22);
+}
+
+TEST_CASE("stringLastCodepoint() BMP test") {
+ auto [index, byteOffset] = StringLastCodepoint("Unicode test \u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5\u7528\u5B57\u7B26\u4E32");
+ CHECK(index == 22);
+ CHECK(byteOffset == 40);
+}
+
+CodepointInfo StringCodepoint(std::string_view str, size_t codepointIdx) {
+ Utf8Iterator it{ str.begin() };
+ Utf8Iterator prev{ it };
+ size_t codepoint = 0;
+
+ Utf8Iterator end{ str.end() };
+ while (true) {
+ if (codepoint == codepointIdx) {
+ return { codepoint, (size_t)std::distance(str.begin(), it.AsInternal()) };
+ }
+ if (it == end) {
+ return { codepoint - 1, (size_t)std::distance(str.begin(), prev.AsInternal()) };
+ }
+
+ codepoint++;
+
+ prev = it;
+ it++;
+ }
+}
+
+TEST_CASE("stringCodepoint() ASCII test") {
+ auto [codepointOffset, byteOffset] = StringCodepoint("This is an ASCII string", 6);
+ CHECK(codepointOffset == 6);
+ CHECK(byteOffset == 6);
+}
+
+TEST_CASE("stringCodepoint() ASCII past-the-end test") {
+ auto [codepointOffset, byteOffset] = StringCodepoint("This is an ASCII string", 100);
+ CHECK(codepointOffset == 22);
+ CHECK(byteOffset == 22);
+}
+
+TEST_CASE("stringCodepoint() BMP test") {
+ auto [codepointOffset, byteOffset] = StringCodepoint("Unicode test \u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5\u7528\u5B57\u7B26\u4E32", 14);
+ CHECK(codepointOffset == 14);
+ CHECK(byteOffset == 16);
+}
+
+TEST_CASE("stringCodepoint() BMP past-the-end test") {
+ auto [codepointOffset, byteOffset] = StringCodepoint("Unicode test \u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5\u7528\u5B57\u7B26\u4E32", 100);
+ CHECK(codepointOffset == 22);
+ CHECK(byteOffset == 40);
+}
diff --git a/core/src/Utils/String.hpp b/core/src/Utils/String.hpp
new file mode 100644
index 0000000..f2829d7
--- /dev/null
+++ b/core/src/Utils/String.hpp
@@ -0,0 +1,84 @@
+#pragma once
+
+#include <cstddef>
+#include <string>
+#include <string_view>
+
+class Utf8Iterator {
+public:
+ using iterator_category = std::bidirectional_iterator_tag;
+ using value_type = char32_t;
+ using difference_type = std::string_view::difference_type;
+ using pointer = const char32_t*;
+ using reference = const char32_t&;
+
+private:
+ std::string_view::iterator mIter;
+ mutable char32_t mCurrentCodePoint = 0;
+ mutable bool mDirty = true;
+
+public:
+ Utf8Iterator(std::string_view::iterator it);
+ ~Utf8Iterator() = default;
+
+ Utf8Iterator(const Utf8Iterator& that) = default;
+ Utf8Iterator& operator=(const Utf8Iterator& that) = default;
+ Utf8Iterator(Utf8Iterator&& that) = default;
+ Utf8Iterator& operator=(Utf8Iterator&& that) = default;
+
+ Utf8Iterator& operator++();
+ Utf8Iterator operator++(int);
+ Utf8Iterator& operator--();
+ Utf8Iterator operator--(int);
+
+ char32_t operator*() const;
+ std::string_view::iterator AsInternal() const;
+
+ friend bool operator==(const Utf8Iterator& lhs, const Utf8Iterator& rhs);
+ friend bool operator!=(const Utf8Iterator& lhs, const Utf8Iterator& rhs);
+ friend bool operator==(const Utf8Iterator& lhs, std::string_view::iterator rhs);
+ friend bool operator!=(const Utf8Iterator& lhs, std::string_view::iterator rhs);
+
+private:
+ void UpdateCurrentValue() const;
+};
+
+class Utf8IterableString {
+private:
+ std::string_view mStr;
+
+public:
+ Utf8IterableString(std::string_view str);
+ Utf8Iterator begin() const;
+ Utf8Iterator end() const;
+};
+
+struct StringEqual {
+ using is_transparent = std::true_type;
+ bool operator()(std::string_view l, std::string_view r) const noexcept { return l == r; }
+};
+struct StringHash {
+ using is_transparent = std::true_type;
+ auto operator()(std::string_view str) const noexcept { return std::hash<std::string_view>{}(str); }
+};
+
+std::u32string ConvertUtf8To32(std::string_view str);
+std::string ConvertUtf32To8(std::u32string_view str);
+
+/// Slice the given UTF-8 string into the given range, in codepoints.
+std::string_view StringRange(std::string_view str, size_t begin, size_t end);
+
+/// Calculate the given UTF-8 string's number of codepoints.
+size_t StringLength(std::string_view str);
+
+struct CodepointInfo {
+ size_t index;
+ size_t byteOffset;
+};
+
+/// Find info about the last codepoint in the given UTF-8 string.
+/// \param str A non-empty UTF-8 encoded string.
+CodepointInfo StringLastCodepoint(std::string_view str);
+/// Find info about the nth codepoint in the given UTF-8 string. If codepointIdx is larger than the length, info for the last codepoint will be returned.
+/// \param str A non-empty UTF-8 encoded string.
+CodepointInfo StringCodepoint(std::string_view str, size_t codepointIdx);
diff --git a/core/src/Utils/fwd.hpp b/core/src/Utils/fwd.hpp
new file mode 100644
index 0000000..1949ca2
--- /dev/null
+++ b/core/src/Utils/fwd.hpp
@@ -0,0 +1,22 @@
+#pragma once
+
+#include "Dialog/fwd.hpp"
+
+// Sigslot.hpp
+class SignalStub;
+template <class... TArgs>
+class Signal;
+class SlotGuard;
+
+// I18n.hpp
+class I18n;
+struct StringArgument;
+struct IntArgument;
+struct FloatArgument;
+class BasicTranslation;
+class FormattedTranslation;
+class NumericTranslation;
+
+// String.hpp
+class Utf8Iterator;
+class Utf8IterableString;