mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 15:00:47 +02:00
1 parent
5f2be59192
commit
497481e8fa
13 files changed
+656
-59
No files matched your search
@@ -18,6 +18,7 @@ add_library(mocktail_input_runtime STATIC
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_native_text_box_info_reader.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_display_state.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_editor.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_font_resolver.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_surface_overlay.cc
|
||||
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_window_input_runtime.cc
|
||||
)
|
||||
@@ -36,6 +37,7 @@ target_link_libraries(mocktail_input_runtime PUBLIC
|
||||
PRIVATE
|
||||
PkgConfig::FONTCONFIG
|
||||
SDL3_ttf::SDL3_ttf
|
||||
nlohmann_json::nlohmann_json
|
||||
)
|
||||
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
|
||||
mocktail_apply_compile_options(mocktail_input_runtime)
|
||||
@@ -68,6 +70,15 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
|
||||
)
|
||||
mocktail_apply_compile_options(roblox_text_display_state_test)
|
||||
|
||||
add_executable(roblox_text_font_resolver_test
|
||||
${MOCKTAIL_INPUT_ROOT}/tests/roblox_text_font_resolver_test.cc
|
||||
)
|
||||
target_link_libraries(roblox_text_font_resolver_test PRIVATE
|
||||
Mocktail::InputRuntime
|
||||
GTest::gtest_main
|
||||
)
|
||||
mocktail_apply_compile_options(roblox_text_font_resolver_test)
|
||||
|
||||
add_executable(roblox_input_native_adapter_test
|
||||
${MOCKTAIL_INPUT_ROOT}/tests/roblox_input_native_adapter_test.cc
|
||||
)
|
||||
@@ -92,6 +103,7 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
|
||||
gtest_discover_tests(roblox_input_router_test)
|
||||
gtest_discover_tests(roblox_text_editor_test)
|
||||
gtest_discover_tests(roblox_text_display_state_test)
|
||||
gtest_discover_tests(roblox_text_font_resolver_test)
|
||||
gtest_discover_tests(roblox_input_native_adapter_test)
|
||||
gtest_discover_tests(roblox_native_text_box_info_reader_test)
|
||||
endif()
|
||||
@@ -57,10 +57,13 @@ struct RobloxTextOverlayPresentation {
|
||||
uint64_t generation = 0;
|
||||
RobloxTextOverlayGeometry geometry;
|
||||
float font_size = 0.0F;
|
||||
bool multiline = false;
|
||||
int32_t x_alignment = 0;
|
||||
int32_t y_alignment = 1;
|
||||
int32_t text_color = 0;
|
||||
int32_t font = 0;
|
||||
int32_t text_input_type = 0;
|
||||
bool text_wrapped = false;
|
||||
std::string display_utf8;
|
||||
std::size_t caret_utf8_byte = 0;
|
||||
std::size_t selection_begin_utf8_byte = 0;
|
||||
|
||||
@@ -67,10 +67,13 @@ struct RobloxTextDisplayUpdate {
|
||||
int32_t area_width = 0;
|
||||
int32_t area_height = 0;
|
||||
float font_size = 0.0F;
|
||||
bool multiline = false;
|
||||
int32_t x_alignment = 0;
|
||||
int32_t y_alignment = 1;
|
||||
int32_t text_color = 0;
|
||||
int32_t font = 0;
|
||||
int32_t text_input_type = 0;
|
||||
bool text_wrapped = false;
|
||||
const char* utf8 = nullptr;
|
||||
std::size_t utf8_size = 0;
|
||||
int32_t cursor_utf16 = 0;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Android's RbxKeyboard resolves the Roblox Font enum through
|
||||
// assets/android/fonts/font-mappings.json. The selected file lives below
|
||||
// assets/content/fonts while legacy SourceSansPro faces live in assets/fonts.
|
||||
struct RobloxTextFontSelection {
|
||||
std::string primary_file;
|
||||
float size_scale = 0.795F;
|
||||
bool exact_mapping = false;
|
||||
};
|
||||
|
||||
RobloxTextFontSelection ResolveRobloxTextFont(
|
||||
int32_t font, const std::string& asset_content_root);
|
||||
RobloxTextFontSelection ResolveRobloxTextFont(int32_t font);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
|
||||
@@ -106,10 +106,13 @@ RobloxTextOverlayPresentation::RobloxTextOverlayPresentation(
|
||||
generation(other.generation),
|
||||
geometry(other.geometry),
|
||||
font_size(other.font_size),
|
||||
multiline(other.multiline),
|
||||
x_alignment(other.x_alignment),
|
||||
y_alignment(other.y_alignment),
|
||||
text_color(other.text_color),
|
||||
font(other.font),
|
||||
text_input_type(other.text_input_type),
|
||||
text_wrapped(other.text_wrapped),
|
||||
display_utf8(other.display_utf8),
|
||||
caret_utf8_byte(other.caret_utf8_byte),
|
||||
selection_begin_utf8_byte(other.selection_begin_utf8_byte),
|
||||
@@ -127,10 +130,13 @@ RobloxTextOverlayPresentation& RobloxTextOverlayPresentation::operator=(
|
||||
generation = other.generation;
|
||||
geometry = other.geometry;
|
||||
font_size = other.font_size;
|
||||
multiline = other.multiline;
|
||||
x_alignment = other.x_alignment;
|
||||
y_alignment = other.y_alignment;
|
||||
text_color = other.text_color;
|
||||
font = other.font;
|
||||
text_input_type = other.text_input_type;
|
||||
text_wrapped = other.text_wrapped;
|
||||
display_utf8 = other.display_utf8;
|
||||
caret_utf8_byte = other.caret_utf8_byte;
|
||||
selection_begin_utf8_byte = other.selection_begin_utf8_byte;
|
||||
@@ -145,10 +151,13 @@ void RobloxTextOverlayPresentation::ClearSensitive() {
|
||||
generation = 0;
|
||||
geometry = {};
|
||||
font_size = 0.0F;
|
||||
multiline = false;
|
||||
x_alignment = 0;
|
||||
y_alignment = 1;
|
||||
text_color = 0;
|
||||
font = 0;
|
||||
text_input_type = 0;
|
||||
text_wrapped = false;
|
||||
caret_utf8_byte = 0;
|
||||
selection_begin_utf8_byte = 0;
|
||||
selection_end_utf8_byte = 0;
|
||||
@@ -208,10 +217,13 @@ Status BuildRobloxTextOverlayPresentation(
|
||||
candidate.generation = update.generation;
|
||||
candidate.geometry = ResolveRobloxTextOverlayGeometry(update, viewport);
|
||||
candidate.font_size = update.font_size;
|
||||
candidate.multiline = update.multiline;
|
||||
candidate.x_alignment = update.x_alignment;
|
||||
candidate.y_alignment = update.y_alignment;
|
||||
candidate.text_color = update.text_color;
|
||||
candidate.font = update.font;
|
||||
candidate.text_input_type = update.text_input_type;
|
||||
candidate.text_wrapped = update.text_wrapped;
|
||||
|
||||
std::size_t codepoints_before_cursor = 0;
|
||||
const std::size_t caret_source_byte = Utf8ByteForUtf16Cursor(
|
||||
|
||||
@@ -725,10 +725,13 @@ void RobloxTextEditor::PublishDisplayLocked(RobloxTextDisplayEvent event) {
|
||||
update.area_width = session_.area_width;
|
||||
update.area_height = session_.area_height;
|
||||
update.font_size = session_.font_size;
|
||||
update.multiline = session_.multiline;
|
||||
update.x_alignment = session_.x_alignment;
|
||||
update.y_alignment = session_.y_alignment;
|
||||
update.text_color = session_.text_color;
|
||||
update.font = session_.font;
|
||||
update.text_input_type = session_.text_input_type;
|
||||
update.text_wrapped = session_.text_wrapped;
|
||||
if (event != RobloxTextDisplayEvent::kHide) {
|
||||
update.utf8 = text_.data();
|
||||
update.utf8_size = text_.size();
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#include "runtime/roblox_text_font_resolver.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
struct FontMapping {
|
||||
std::string file;
|
||||
float size_scale = 0.795F;
|
||||
};
|
||||
|
||||
using FontMappings = std::unordered_map<int32_t, FontMapping>;
|
||||
|
||||
std::mutex g_catalog_mutex;
|
||||
std::unordered_map<std::string, FontMappings> g_catalogs;
|
||||
|
||||
bool IsSafeFontFileName(const std::string& value) {
|
||||
if (value.empty()) {
|
||||
return false;
|
||||
}
|
||||
const std::filesystem::path path(value);
|
||||
return !path.is_absolute() && !path.has_parent_path() &&
|
||||
path.filename() == path;
|
||||
}
|
||||
|
||||
bool IsRegularFile(const std::filesystem::path& path) {
|
||||
std::error_code error;
|
||||
return std::filesystem::is_regular_file(path, error) && !error;
|
||||
}
|
||||
|
||||
FontMappings LoadMappings(const std::filesystem::path& asset_content_root) {
|
||||
FontMappings mappings;
|
||||
const std::filesystem::path mapping_file =
|
||||
asset_content_root.parent_path() / "android/fonts/font-mappings.json";
|
||||
std::ifstream input(mapping_file, std::ios::binary);
|
||||
if (!input) {
|
||||
return mappings;
|
||||
}
|
||||
const nlohmann::json document = nlohmann::json::parse(input, nullptr, false);
|
||||
if (document.is_discarded() || !document.is_array()) {
|
||||
return mappings;
|
||||
}
|
||||
for (const nlohmann::json& item : document) {
|
||||
if (!item.is_object()) {
|
||||
continue;
|
||||
}
|
||||
const auto enum_value = item.find("enum");
|
||||
const auto font_value = item.find("font");
|
||||
const auto ratio_value = item.find("fromRbxFontRatio");
|
||||
if (enum_value == item.end() || !enum_value->is_number_integer() ||
|
||||
font_value == item.end() || !font_value->is_string() ||
|
||||
ratio_value == item.end() || !ratio_value->is_number()) {
|
||||
continue;
|
||||
}
|
||||
const int64_t encoded_enum = enum_value->get<int64_t>();
|
||||
const double encoded_ratio = ratio_value->get<double>();
|
||||
const std::string encoded_font = font_value->get<std::string>();
|
||||
if (encoded_enum < 0 || encoded_enum > INT32_MAX ||
|
||||
!std::isfinite(encoded_ratio) || encoded_ratio <= 0.0 ||
|
||||
encoded_ratio > 4.0 || !IsSafeFontFileName(encoded_font)) {
|
||||
continue;
|
||||
}
|
||||
mappings[static_cast<int32_t>(encoded_enum)] = {
|
||||
encoded_font, static_cast<float>(encoded_ratio)};
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
std::optional<FontMapping> MappingFor(
|
||||
int32_t font, const std::filesystem::path& asset_content_root) {
|
||||
const std::string key = asset_content_root.lexically_normal().string();
|
||||
std::lock_guard<std::mutex> lock(g_catalog_mutex);
|
||||
auto catalog = g_catalogs.find(key);
|
||||
if (catalog == g_catalogs.end()) {
|
||||
catalog = g_catalogs.emplace(key, LoadMappings(asset_content_root)).first;
|
||||
}
|
||||
const auto mapped = catalog->second.find(font);
|
||||
return mapped == catalog->second.end()
|
||||
? std::optional<FontMapping>()
|
||||
: std::optional<FontMapping>(mapped->second);
|
||||
}
|
||||
|
||||
std::string LegacyFontName(int32_t font) {
|
||||
if (font == 4) {
|
||||
return "SourceSansPro-Bold.ttf";
|
||||
}
|
||||
if (font == 5) {
|
||||
return "SourceSansPro-Light.ttf";
|
||||
}
|
||||
return "SourceSansPro-Regular.ttf";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RobloxTextFontSelection ResolveRobloxTextFont(
|
||||
int32_t font, const std::string& asset_content_root) {
|
||||
RobloxTextFontSelection selection;
|
||||
if (asset_content_root.empty()) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
const std::filesystem::path content(asset_content_root);
|
||||
const std::optional<FontMapping> mapped = MappingFor(font, content);
|
||||
if (mapped.has_value()) {
|
||||
const std::filesystem::path candidate = content / "fonts" / mapped->file;
|
||||
if (IsRegularFile(candidate)) {
|
||||
selection.primary_file = candidate.lexically_normal().string();
|
||||
selection.size_scale = mapped->size_scale;
|
||||
selection.exact_mapping = true;
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
|
||||
const std::filesystem::path legacy =
|
||||
content.parent_path() / "fonts" / LegacyFontName(font);
|
||||
if (IsRegularFile(legacy)) {
|
||||
selection.primary_file = legacy.lexically_normal().string();
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
RobloxTextFontSelection ResolveRobloxTextFont(int32_t font) {
|
||||
const char* content = std::getenv("MOCKTAIL_ASSET_PATH");
|
||||
return ResolveRobloxTextFont(
|
||||
font, content != nullptr && content[0] != '\0' ? content : "");
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -21,6 +21,7 @@ namespace {
|
||||
|
||||
constexpr std::size_t kMaximumPendingTextCommands = 64;
|
||||
constexpr std::size_t kMaximumPendingTextBytes = 4U * 1024U * 1024U;
|
||||
constexpr uint32_t kMissingGeometryRetryPumpInterval = 8;
|
||||
|
||||
void SecureClear(std::string* value) {
|
||||
if (value == nullptr) {
|
||||
@@ -164,7 +165,13 @@ class ProductionTextInputBackend final
|
||||
};
|
||||
|
||||
struct RobloxTextInputJniBridge::State {
|
||||
enum class CommandType { kShow, kHide, kReplaceText, kRefreshProperties };
|
||||
enum class CommandType {
|
||||
kShow,
|
||||
kHide,
|
||||
kReplaceText,
|
||||
kRefreshProperties,
|
||||
kRecoverGeometry
|
||||
};
|
||||
|
||||
struct Command {
|
||||
CommandType type = CommandType::kHide;
|
||||
@@ -198,6 +205,7 @@ struct RobloxTextInputJniBridge::State {
|
||||
desired_active = false;
|
||||
active_geometry = {};
|
||||
has_active_geometry = false;
|
||||
missing_geometry_retry_pumps = 0;
|
||||
ClearCommandsLocked();
|
||||
}
|
||||
|
||||
@@ -321,6 +329,7 @@ struct RobloxTextInputJniBridge::State {
|
||||
if (HasUsableGeometry(effective_info)) {
|
||||
active_geometry = effective_info;
|
||||
has_active_geometry = true;
|
||||
missing_geometry_retry_pumps = 0;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [input] TextBox focus snapshot native=%d "
|
||||
@@ -345,6 +354,7 @@ struct RobloxTextInputJniBridge::State {
|
||||
active_generation = generation;
|
||||
active_handle = request.text_box;
|
||||
desired_active = true;
|
||||
missing_geometry_retry_pumps = 0;
|
||||
command.type = CommandType::kShow;
|
||||
command.generation = generation;
|
||||
command.textbox_handle = request.text_box;
|
||||
@@ -372,6 +382,7 @@ struct RobloxTextInputJniBridge::State {
|
||||
active_info = {};
|
||||
active_geometry = {};
|
||||
has_active_geometry = false;
|
||||
missing_geometry_retry_pumps = 0;
|
||||
}
|
||||
|
||||
void ReplaceText(const std::string& text) {
|
||||
@@ -426,6 +437,41 @@ struct RobloxTextInputJniBridge::State {
|
||||
commands.push_back(std::move(command));
|
||||
}
|
||||
|
||||
// The first showKeyboard snapshot can precede Roblox's layout pass and
|
||||
// contain a 0x0 rectangle. Keep the editor authoritative, then retry the
|
||||
// typed native query on the main-thread pump until real bounds appear.
|
||||
void AppendMissingGeometryRetry(std::deque<Command>* pending) {
|
||||
if (pending == nullptr) {
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (!accepting || !desired_active || has_active_geometry ||
|
||||
active_generation == 0 || active_handle == 0 || !applied_active ||
|
||||
applied_generation != active_generation ||
|
||||
applied_handle != active_handle) {
|
||||
missing_geometry_retry_pumps = 0;
|
||||
return;
|
||||
}
|
||||
const auto already_refreshing =
|
||||
std::find_if(pending->begin(), pending->end(), [](const Command& item) {
|
||||
return item.type == CommandType::kRefreshProperties ||
|
||||
item.type == CommandType::kRecoverGeometry;
|
||||
});
|
||||
if (already_refreshing != pending->end()) {
|
||||
return;
|
||||
}
|
||||
++missing_geometry_retry_pumps;
|
||||
if (missing_geometry_retry_pumps < kMissingGeometryRetryPumpInterval) {
|
||||
return;
|
||||
}
|
||||
missing_geometry_retry_pumps = 0;
|
||||
Command command;
|
||||
command.type = CommandType::kRecoverGeometry;
|
||||
command.generation = active_generation;
|
||||
command.textbox_handle = active_handle;
|
||||
pending->push_back(std::move(command));
|
||||
}
|
||||
|
||||
bool IsDesiredFocusCurrent(uint64_t requested_generation,
|
||||
int64_t requested_handle) const {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
@@ -456,12 +502,25 @@ struct RobloxTextInputJniBridge::State {
|
||||
} else {
|
||||
active_geometry = candidate;
|
||||
has_active_geometry = true;
|
||||
missing_geometry_retry_pumps = 0;
|
||||
}
|
||||
active_info = candidate;
|
||||
*effective_info = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ForgetRecoveredGeometryIfCurrent(uint64_t requested_generation,
|
||||
int64_t requested_handle) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (accepting && desired_active &&
|
||||
active_generation == requested_generation &&
|
||||
active_handle == requested_handle) {
|
||||
active_geometry = {};
|
||||
has_active_geometry = false;
|
||||
missing_geometry_retry_pumps = 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool Drain() {
|
||||
std::deque<Command> pending;
|
||||
bool terminal = false;
|
||||
@@ -479,6 +538,8 @@ struct RobloxTextInputJniBridge::State {
|
||||
return CompleteTerminalOnMainThread("queue");
|
||||
}
|
||||
|
||||
AppendMissingGeometryRetry(&pending);
|
||||
|
||||
bool success = true;
|
||||
for (Command& command : pending) {
|
||||
if (!CompletionStatus().ok()) {
|
||||
@@ -490,6 +551,9 @@ struct RobloxTextInputJniBridge::State {
|
||||
Status status = Status::Ok();
|
||||
const char* failure_message = "Roblox text-input command failed";
|
||||
const char* failure_stage = "command";
|
||||
const bool best_effort_geometry_recovery =
|
||||
command.type == CommandType::kRecoverGeometry;
|
||||
bool presentation_properties_updated = false;
|
||||
if (command.type == CommandType::kShow) {
|
||||
failure_message = "Roblox TextBox focus session failed";
|
||||
failure_stage = "begin-focus";
|
||||
@@ -514,10 +578,13 @@ struct RobloxTextInputJniBridge::State {
|
||||
session.area_width = area.width;
|
||||
session.area_height = area.height;
|
||||
session.font_size = command.info.font_size;
|
||||
session.font = command.info.font;
|
||||
session.x_alignment = command.info.x_alignment;
|
||||
session.y_alignment = command.info.y_alignment;
|
||||
session.text_color = command.info.text_color;
|
||||
session.text_input_type = command.info.text_input_type;
|
||||
session.return_key_type = command.info.return_key_type;
|
||||
session.text_wrapped = command.info.text_wrapped;
|
||||
status = backend->BeginTextFocusSession(std::move(session));
|
||||
const window::TextInputOptions options{command.info.text_input_type,
|
||||
command.info.multiline};
|
||||
@@ -581,6 +648,7 @@ struct RobloxTextInputJniBridge::State {
|
||||
failure_stage = "update-properties";
|
||||
status = backend->UpdateTextFocusProperties(
|
||||
command.generation, ToFocusProperties(effective_info));
|
||||
presentation_properties_updated = status.ok();
|
||||
const window::TextInputArea area = ToTextInputArea(effective_info);
|
||||
const window::TextInputOptions options{
|
||||
effective_info.text_input_type, effective_info.multiline};
|
||||
@@ -602,6 +670,20 @@ struct RobloxTextInputJniBridge::State {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best_effort_geometry_recovery && !status.ok()) {
|
||||
if (!presentation_properties_updated) {
|
||||
ForgetRecoveredGeometryIfCurrent(command.generation,
|
||||
command.textbox_handle);
|
||||
}
|
||||
if (!logged_geometry_retry_error) {
|
||||
logged_geometry_retry_error = true;
|
||||
std::fprintf(stderr,
|
||||
" [input] deferred TextBox geometry query failed; "
|
||||
"typing remains active: %s\n",
|
||||
status.message().c_str());
|
||||
}
|
||||
status = Status::Ok();
|
||||
}
|
||||
command.ClearSensitiveData();
|
||||
if (status.ok()) {
|
||||
const Status terminal_status = CompletionStatus();
|
||||
@@ -664,6 +746,7 @@ struct RobloxTextInputJniBridge::State {
|
||||
active_info = {};
|
||||
active_geometry = {};
|
||||
has_active_geometry = false;
|
||||
missing_geometry_retry_pumps = 0;
|
||||
ClearCommandsLocked();
|
||||
}
|
||||
|
||||
@@ -703,12 +786,14 @@ struct RobloxTextInputJniBridge::State {
|
||||
jnivm::RobloxTextBoxInfo active_info;
|
||||
jnivm::RobloxTextBoxInfo active_geometry;
|
||||
bool has_active_geometry = false;
|
||||
uint32_t missing_geometry_retry_pumps = 0;
|
||||
bool desired_active = false;
|
||||
std::size_t pending_text_bytes = 0;
|
||||
bool accepting = true;
|
||||
bool terminal_cleanup_completed = false;
|
||||
bool logged_missing_overlay_geometry = false;
|
||||
bool logged_property_refresh = false;
|
||||
bool logged_geometry_retry_error = false;
|
||||
bool applied_active = false;
|
||||
uint64_t applied_generation = 0;
|
||||
int64_t applied_handle = 0;
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "runtime/roblox_text_font_resolver.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
@@ -62,9 +64,8 @@ bool IsUtf8Continuation(unsigned char value) {
|
||||
}
|
||||
|
||||
SensitiveString VisibleTextWindow(
|
||||
const RobloxTextOverlayPresentation& presentation,
|
||||
std::size_t* caret_byte, std::size_t* selection_begin_byte,
|
||||
std::size_t* selection_end_byte) {
|
||||
const RobloxTextOverlayPresentation& presentation, std::size_t* caret_byte,
|
||||
std::size_t* selection_begin_byte, std::size_t* selection_end_byte) {
|
||||
SensitiveString result;
|
||||
const std::string& text = presentation.display_utf8;
|
||||
const std::size_t caret = std::min(presentation.caret_utf8_byte, text.size());
|
||||
@@ -107,16 +108,18 @@ SensitiveString VisibleTextWindow(
|
||||
};
|
||||
*selection_begin_byte =
|
||||
map_to_window(std::min(selection_begin, selection_end));
|
||||
*selection_end_byte =
|
||||
map_to_window(std::max(selection_begin, selection_end));
|
||||
*selection_end_byte = map_to_window(std::max(selection_begin, selection_end));
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> ResolveFontFiles() {
|
||||
std::vector<std::string> ResolveFontFiles(
|
||||
const RobloxTextFontSelection& selection) {
|
||||
std::vector<std::string> files;
|
||||
const char* configured = std::getenv("MOCKTAIL_TEXT_FONT");
|
||||
if (configured != nullptr && configured[0] != '\0') {
|
||||
files.emplace_back(configured);
|
||||
} else if (!selection.primary_file.empty()) {
|
||||
files.push_back(selection.primary_file);
|
||||
}
|
||||
if (!FcInit()) {
|
||||
return files;
|
||||
@@ -234,20 +237,31 @@ SDL_Color ResolveTextColor(std::int32_t packed_color) {
|
||||
static_cast<std::uint8_t>(packed), alpha};
|
||||
}
|
||||
|
||||
int TextByteX(TTF_Text* layout, std::size_t byte_offset, int text_width) {
|
||||
struct TextBytePosition {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
TextBytePosition TextPosition(TTF_Text* layout, std::size_t byte_offset,
|
||||
int text_width) {
|
||||
if (layout == nullptr) {
|
||||
return 0;
|
||||
return {};
|
||||
}
|
||||
TTF_SubString substring{};
|
||||
const int offset = static_cast<int>(std::min<std::size_t>(
|
||||
byte_offset, static_cast<std::size_t>(std::numeric_limits<int>::max())));
|
||||
if (!TTF_GetTextSubString(layout, offset, &substring)) {
|
||||
return 0;
|
||||
return {};
|
||||
}
|
||||
TextBytePosition position;
|
||||
position.x = substring.rect.x;
|
||||
position.y = substring.rect.y;
|
||||
position.height = substring.rect.h;
|
||||
if ((substring.flags & TTF_SUBSTRING_TEXT_END) != 0) {
|
||||
return std::min(text_width, substring.rect.x + substring.rect.w);
|
||||
position.x = std::min(text_width, substring.rect.x + substring.rect.w);
|
||||
}
|
||||
return substring.rect.x;
|
||||
return position;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -337,8 +351,7 @@ void RobloxTextSurfaceOverlay::ApplyUpdate(
|
||||
++state_revision_;
|
||||
}
|
||||
const RobloxTextOverlayPresentation& presentation = state_.presentation();
|
||||
const bool drawable =
|
||||
presentation.visible && presentation.geometry.valid();
|
||||
const bool drawable = presentation.visible && presentation.geometry.valid();
|
||||
if (!drawable) {
|
||||
ClearFrameLocked();
|
||||
}
|
||||
@@ -414,18 +427,31 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
|
||||
std::vector<std::uint8_t> candidate(
|
||||
static_cast<std::size_t>(width * height * 4), 0);
|
||||
const int frame_width = static_cast<int>(width);
|
||||
const int frame_height = static_cast<int>(height);
|
||||
const int padding = std::clamp(frame_width / 12, 8, 14);
|
||||
const int clip_left = std::min(padding, frame_width);
|
||||
const int clip_right = std::max(clip_left, frame_width - padding);
|
||||
const int clip_top = 3;
|
||||
const int clip_bottom = std::max(clip_top, frame_height - 3);
|
||||
const int available_width = std::max(1, clip_right - clip_left);
|
||||
const bool wrapped_layout =
|
||||
presentation.multiline || presentation.text_wrapped;
|
||||
|
||||
if (!TTF_Init()) {
|
||||
SecureClear(&candidate);
|
||||
return RasterizationFailure("unable to initialize SDL3_ttf");
|
||||
}
|
||||
const RobloxTextFontSelection font_selection =
|
||||
ResolveRobloxTextFont(presentation.font);
|
||||
const float native_point_size =
|
||||
std::isfinite(presentation.font_size) ? presentation.font_size : 0.0F;
|
||||
const float point_size =
|
||||
native_point_size > 0.0F
|
||||
? std::clamp(native_point_size, 8.0F, 96.0F)
|
||||
? std::clamp(native_point_size * font_selection.size_scale, 1.0F,
|
||||
512.0F)
|
||||
: std::clamp(static_cast<float>(height) * 0.38F, 12.0F, 28.0F);
|
||||
const std::vector<std::string> font_files = ResolveFontFiles();
|
||||
const std::vector<std::string> font_files = ResolveFontFiles(font_selection);
|
||||
std::vector<TTF_Font*> fonts;
|
||||
fonts.reserve(font_files.size());
|
||||
for (const std::string& file : font_files) {
|
||||
@@ -437,34 +463,46 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
if (fonts.empty()) {
|
||||
TTF_Quit();
|
||||
SecureClear(&candidate);
|
||||
return RasterizationFailure("unable to open a Fontconfig sans font");
|
||||
return RasterizationFailure("unable to open a Roblox or fallback font");
|
||||
}
|
||||
for (std::size_t index = 1; index < fonts.size(); ++index) {
|
||||
(void)TTF_AddFallbackFont(fonts.front(), fonts[index]);
|
||||
}
|
||||
if (wrapped_layout) {
|
||||
TTF_HorizontalAlignment alignment = TTF_HORIZONTAL_ALIGN_LEFT;
|
||||
if (presentation.x_alignment == 1) {
|
||||
alignment = TTF_HORIZONTAL_ALIGN_RIGHT;
|
||||
} else if (presentation.x_alignment == 2) {
|
||||
alignment = TTF_HORIZONTAL_ALIGN_CENTER;
|
||||
}
|
||||
TTF_SetFontWrapAlignment(fonts.front(), alignment);
|
||||
}
|
||||
|
||||
std::size_t caret_byte = 0;
|
||||
std::size_t selection_begin_byte = 0;
|
||||
std::size_t selection_end_byte = 0;
|
||||
SensitiveString text =
|
||||
VisibleTextWindow(presentation, &caret_byte, &selection_begin_byte,
|
||||
&selection_end_byte);
|
||||
SensitiveString text = VisibleTextWindow(
|
||||
presentation, &caret_byte, &selection_begin_byte, &selection_end_byte);
|
||||
SDL_Surface* rendered = nullptr;
|
||||
SDL_Surface* converted = nullptr;
|
||||
const SDL_Color text_color = ResolveTextColor(presentation.text_color);
|
||||
TTF_Text* layout = TTF_CreateText(nullptr, fonts.front(), text.value.data(),
|
||||
text.value.size());
|
||||
if (layout != nullptr && wrapped_layout) {
|
||||
(void)TTF_SetTextWrapWidth(layout, available_width);
|
||||
}
|
||||
int text_width = 0;
|
||||
int text_height = std::max(1, TTF_GetFontHeight(fonts.front()));
|
||||
int caret_x = 0;
|
||||
int selection_begin_x = 0;
|
||||
int selection_end_x = 0;
|
||||
TextBytePosition caret_position;
|
||||
TextBytePosition selection_begin_position;
|
||||
TextBytePosition selection_end_position;
|
||||
if (layout != nullptr) {
|
||||
(void)TTF_GetTextSize(layout, &text_width, &text_height);
|
||||
caret_x = TextByteX(layout, caret_byte, text_width);
|
||||
selection_begin_x =
|
||||
TextByteX(layout, selection_begin_byte, text_width);
|
||||
selection_end_x = TextByteX(layout, selection_end_byte, text_width);
|
||||
caret_position = TextPosition(layout, caret_byte, text_width);
|
||||
selection_begin_position =
|
||||
TextPosition(layout, selection_begin_byte, text_width);
|
||||
selection_end_position =
|
||||
TextPosition(layout, selection_end_byte, text_width);
|
||||
}
|
||||
if (!text.value.empty()) {
|
||||
// SDL_ttf implementations have historically differed on whether fg.a is
|
||||
@@ -472,8 +510,12 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
// Android ARGB opacity explicitly while copying the straight-alpha mask.
|
||||
const SDL_Color raster_color = {text_color.r, text_color.g, text_color.b,
|
||||
255};
|
||||
rendered = TTF_RenderText_Blended(fonts.front(), text.value.data(),
|
||||
text.value.size(), raster_color);
|
||||
rendered = wrapped_layout
|
||||
? TTF_RenderText_Blended_Wrapped(
|
||||
fonts.front(), text.value.data(), text.value.size(),
|
||||
raster_color, available_width)
|
||||
: TTF_RenderText_Blended(fonts.front(), text.value.data(),
|
||||
text.value.size(), raster_color);
|
||||
if (rendered != nullptr) {
|
||||
converted = SDL_ConvertSurface(rendered, SDL_PIXELFORMAT_RGBA32);
|
||||
}
|
||||
@@ -483,23 +525,15 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
if (!text.value.empty() && (rendered == nullptr || converted == nullptr)) {
|
||||
status = RasterizationFailure("unable to rasterize Unicode text");
|
||||
} else {
|
||||
const int frame_width = static_cast<int>(width);
|
||||
const int frame_height = static_cast<int>(height);
|
||||
const int padding = std::clamp(frame_width / 12, 8, 14);
|
||||
const int clip_left = std::min(padding, frame_width);
|
||||
const int clip_right = std::max(clip_left, frame_width - padding);
|
||||
const int clip_top = 3;
|
||||
const int clip_bottom = std::max(clip_top, frame_height - 3);
|
||||
const int available_width = std::max(1, clip_right - clip_left);
|
||||
int text_offset = 0;
|
||||
if (text_width <= available_width) {
|
||||
if (!wrapped_layout && text_width <= available_width) {
|
||||
if (presentation.x_alignment == 1) {
|
||||
text_offset = available_width - text_width;
|
||||
} else if (presentation.x_alignment == 2) {
|
||||
text_offset = (available_width - text_width) / 2;
|
||||
}
|
||||
} else if (caret_x > available_width - 3) {
|
||||
text_offset = available_width - 3 - caret_x;
|
||||
} else if (!wrapped_layout && caret_position.x > available_width - 3) {
|
||||
text_offset = available_width - 3 - caret_position.x;
|
||||
}
|
||||
int text_y = clip_top;
|
||||
if (presentation.y_alignment == 1) {
|
||||
@@ -507,21 +541,53 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
} else if (presentation.y_alignment == 2) {
|
||||
text_y = std::max(clip_top, clip_bottom - text_height);
|
||||
}
|
||||
if (selection_begin_byte != selection_end_byte) {
|
||||
const int selection_left = std::clamp(
|
||||
clip_left + text_offset + std::min(selection_begin_x, selection_end_x),
|
||||
clip_left, clip_right);
|
||||
const int selection_right = std::clamp(
|
||||
clip_left + text_offset + std::max(selection_begin_x, selection_end_x),
|
||||
clip_left, clip_right);
|
||||
const int selection_top = std::clamp(text_y, clip_top, clip_bottom);
|
||||
const int selection_bottom = std::clamp(
|
||||
text_y + text_height, selection_top, clip_bottom);
|
||||
for (int y = selection_top; y < selection_bottom; ++y) {
|
||||
for (int x = selection_left; x < selection_right; ++x) {
|
||||
const auto fill_highlight = [&](int left, int top, int right, int bottom) {
|
||||
const int bounded_left = std::clamp(left, clip_left, clip_right);
|
||||
const int bounded_right = std::clamp(right, bounded_left, clip_right);
|
||||
const int bounded_top = std::clamp(top, clip_top, clip_bottom);
|
||||
const int bounded_bottom = std::clamp(bottom, bounded_top, clip_bottom);
|
||||
for (int y = bounded_top; y < bounded_bottom; ++y) {
|
||||
for (int x = bounded_left; x < bounded_right; ++x) {
|
||||
SetPixel(&candidate, frame_width, x, y, 65, 132, 228, 112);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (selection_begin_byte != selection_end_byte) {
|
||||
if (wrapped_layout && layout != nullptr) {
|
||||
const std::size_t range_bytes =
|
||||
selection_end_byte - selection_begin_byte;
|
||||
const int range_offset = static_cast<int>(std::min<std::size_t>(
|
||||
selection_begin_byte,
|
||||
static_cast<std::size_t>(std::numeric_limits<int>::max())));
|
||||
const int range_length = static_cast<int>(std::min<std::size_t>(
|
||||
range_bytes,
|
||||
static_cast<std::size_t>(std::numeric_limits<int>::max())));
|
||||
int count = 0;
|
||||
TTF_SubString** substrings = TTF_GetTextSubStringsForRange(
|
||||
layout, range_offset, range_length, &count);
|
||||
if (substrings != nullptr) {
|
||||
for (int index = 0; index < count; ++index) {
|
||||
const TTF_SubString* substring = substrings[index];
|
||||
if (substring == nullptr) {
|
||||
continue;
|
||||
}
|
||||
fill_highlight(
|
||||
clip_left + text_offset + substring->rect.x,
|
||||
text_y + substring->rect.y,
|
||||
clip_left + text_offset + substring->rect.x + substring->rect.w,
|
||||
text_y + substring->rect.y + substring->rect.h);
|
||||
}
|
||||
SDL_free(substrings);
|
||||
}
|
||||
} else {
|
||||
fill_highlight(
|
||||
clip_left + text_offset +
|
||||
std::min(selection_begin_position.x, selection_end_position.x),
|
||||
text_y,
|
||||
clip_left + text_offset +
|
||||
std::max(selection_begin_position.x, selection_end_position.x),
|
||||
text_y + text_height);
|
||||
}
|
||||
}
|
||||
if (converted != nullptr) {
|
||||
BlendSurface(converted, clip_left + text_offset, text_y, clip_left,
|
||||
@@ -529,12 +595,17 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
|
||||
&candidate);
|
||||
}
|
||||
const int caret_screen_x =
|
||||
std::clamp(clip_left + text_offset + caret_x, clip_left,
|
||||
std::clamp(clip_left + text_offset + caret_position.x, clip_left,
|
||||
std::max(clip_left, clip_right - 2));
|
||||
const int caret_line_height =
|
||||
caret_position.height > 0
|
||||
? caret_position.height
|
||||
: std::max(1, TTF_GetFontHeight(fonts.front()));
|
||||
const int caret_height =
|
||||
std::min(std::max(12, text_height), clip_bottom - clip_top);
|
||||
const int caret_y = std::clamp(
|
||||
text_y, clip_top, std::max(clip_top, clip_bottom - caret_height));
|
||||
std::min(std::max(12, caret_line_height), clip_bottom - clip_top);
|
||||
const int caret_y =
|
||||
std::clamp(text_y + caret_position.y, clip_top,
|
||||
std::max(clip_top, clip_bottom - caret_height));
|
||||
for (int y = caret_y; y < caret_y + caret_height; ++y) {
|
||||
SetPixel(&candidate, frame_width, caret_screen_x, y, text_color.r,
|
||||
text_color.g, text_color.b, text_color.a);
|
||||
|
||||
@@ -131,6 +131,25 @@ TEST(RobloxTextDisplayStateTest, MapsUtf16CursorToUnicodeByteBoundary) {
|
||||
EXPECT_EQ(presentation.caret_utf8_byte, std::string(u8"A😀").size());
|
||||
}
|
||||
|
||||
TEST(RobloxTextDisplayStateTest, PreservesRobloxFontAndWrappingProperties) {
|
||||
const std::string text = "styled";
|
||||
RobloxTextDisplayUpdate update = Show(7, text, 6);
|
||||
update.font_size = 21.0F;
|
||||
update.font = 13;
|
||||
update.multiline = true;
|
||||
update.text_wrapped = true;
|
||||
RobloxTextOverlayPresentation presentation;
|
||||
|
||||
ASSERT_TRUE(BuildRobloxTextOverlayPresentation(update, {1280, 720},
|
||||
&presentation)
|
||||
.ok());
|
||||
|
||||
EXPECT_FLOAT_EQ(presentation.font_size, 21.0F);
|
||||
EXPECT_EQ(presentation.font, 13);
|
||||
EXPECT_TRUE(presentation.multiline);
|
||||
EXPECT_TRUE(presentation.text_wrapped);
|
||||
}
|
||||
|
||||
TEST(RobloxTextDisplayStateTest, MapsUnicodeSelectionToVisibleByteRange) {
|
||||
const std::string text = u8"A😀Б";
|
||||
RobloxTextDisplayUpdate update = Show(7, text, 3);
|
||||
@@ -138,9 +157,9 @@ TEST(RobloxTextDisplayStateTest, MapsUnicodeSelectionToVisibleByteRange) {
|
||||
update.selection_end_utf16 = 3;
|
||||
RobloxTextOverlayPresentation presentation;
|
||||
|
||||
ASSERT_TRUE(BuildRobloxTextOverlayPresentation(update, {1280, 720},
|
||||
&presentation)
|
||||
.ok());
|
||||
ASSERT_TRUE(
|
||||
BuildRobloxTextOverlayPresentation(update, {1280, 720}, &presentation)
|
||||
.ok());
|
||||
|
||||
EXPECT_EQ(presentation.selection_begin_utf8_byte, std::string("A").size());
|
||||
EXPECT_EQ(presentation.selection_end_utf8_byte,
|
||||
@@ -385,6 +404,37 @@ TEST(RobloxTextDisplayStateTest, RasterizesSelectionHighlightBehindText) {
|
||||
EXPECT_TRUE(overlay.Shutdown().ok());
|
||||
}
|
||||
|
||||
TEST(RobloxTextDisplayStateTest, WrapsMultilineTextInsideNativeTextBox) {
|
||||
RobloxTextSurfaceOverlay overlay;
|
||||
ASSERT_TRUE(overlay.Initialize({800, 600}).ok());
|
||||
const std::string text =
|
||||
"one two three four five six seven eight nine ten eleven twelve";
|
||||
RobloxTextDisplayUpdate show = Show(1, text, 0);
|
||||
show.area_width = 150;
|
||||
show.area_height = 140;
|
||||
show.font_size = 20.0F;
|
||||
show.y_alignment = 0;
|
||||
show.text_wrapped = true;
|
||||
RobloxTextDisplaySink sink = overlay.sink();
|
||||
sink.update(sink.context, show);
|
||||
|
||||
MocktailTextOverlayFrameInfo frame;
|
||||
ASSERT_TRUE(overlay.QueryFrame(&frame));
|
||||
std::vector<std::uint8_t> rgba(frame.rgba_bytes);
|
||||
ASSERT_TRUE(overlay.CopyFrame(frame.revision, rgba.data(), rgba.size()));
|
||||
bool has_lower_line_pixel = false;
|
||||
for (std::uint32_t y = 40; y < frame.height; ++y) {
|
||||
for (std::uint32_t x = 0; x < frame.width; ++x) {
|
||||
const std::size_t alpha =
|
||||
(static_cast<std::size_t>(y) * frame.width + x) * 4 + 3;
|
||||
has_lower_line_pixel =
|
||||
has_lower_line_pixel || (alpha < rgba.size() && rgba[alpha] != 0);
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(has_lower_line_pixel);
|
||||
EXPECT_TRUE(overlay.Shutdown().ok());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -35,7 +35,11 @@ struct DisplayCall {
|
||||
int32_t area_y = 0;
|
||||
int32_t area_width = 0;
|
||||
int32_t area_height = 0;
|
||||
float font_size = 0.0F;
|
||||
bool multiline = false;
|
||||
int32_t font = 0;
|
||||
int32_t text_input_type = 0;
|
||||
bool text_wrapped = false;
|
||||
std::string text;
|
||||
int32_t cursor_utf16 = 0;
|
||||
int32_t selection_begin_utf16 = 0;
|
||||
@@ -55,7 +59,11 @@ void DisplayUpdate(void* context, const RobloxTextDisplayUpdate& update) {
|
||||
call.area_y = update.area_y;
|
||||
call.area_width = update.area_width;
|
||||
call.area_height = update.area_height;
|
||||
call.font_size = update.font_size;
|
||||
call.multiline = update.multiline;
|
||||
call.font = update.font;
|
||||
call.text_input_type = update.text_input_type;
|
||||
call.text_wrapped = update.text_wrapped;
|
||||
if (update.utf8 != nullptr) {
|
||||
call.text.assign(update.utf8, update.utf8_size);
|
||||
}
|
||||
@@ -631,7 +639,11 @@ TEST(RobloxTextEditorTest,
|
||||
EXPECT_EQ(refresh.area_y, 36);
|
||||
EXPECT_EQ(refresh.area_width, 633);
|
||||
EXPECT_EQ(refresh.area_height, 36);
|
||||
EXPECT_FLOAT_EQ(refresh.font_size, 16.0F);
|
||||
EXPECT_TRUE(refresh.multiline);
|
||||
EXPECT_EQ(refresh.font, 4);
|
||||
EXPECT_EQ(refresh.text_input_type, 3);
|
||||
EXPECT_TRUE(refresh.text_wrapped);
|
||||
EXPECT_EQ(refresh.text, "unchanged");
|
||||
EXPECT_EQ(refresh.cursor_utf16, 9);
|
||||
EXPECT_EQ(editor.Snapshot().generation, 7u);
|
||||
@@ -649,6 +661,10 @@ TEST(RobloxTextEditorTest,
|
||||
session.area_y = 34;
|
||||
session.area_width = 280;
|
||||
session.area_height = 44;
|
||||
session.font_size = 22.0F;
|
||||
session.font = 13;
|
||||
session.multiline = true;
|
||||
session.text_wrapped = true;
|
||||
session.text_input_type = 5;
|
||||
|
||||
ASSERT_TRUE(editor.BeginFocusSession(std::move(session)).ok());
|
||||
@@ -659,7 +675,11 @@ TEST(RobloxTextEditorTest,
|
||||
EXPECT_EQ(display_probe.calls[0].area_y, 34);
|
||||
EXPECT_EQ(display_probe.calls[0].area_width, 280);
|
||||
EXPECT_EQ(display_probe.calls[0].area_height, 44);
|
||||
EXPECT_FLOAT_EQ(display_probe.calls[0].font_size, 22.0F);
|
||||
EXPECT_TRUE(display_probe.calls[0].multiline);
|
||||
EXPECT_EQ(display_probe.calls[0].font, 13);
|
||||
EXPECT_EQ(display_probe.calls[0].text_input_type, 5);
|
||||
EXPECT_TRUE(display_probe.calls[0].text_wrapped);
|
||||
EXPECT_EQ(display_probe.calls[0].text, "initial");
|
||||
EXPECT_EQ(display_probe.calls[0].cursor_utf16, 7);
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#include "runtime/roblox_text_font_resolver.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
class TemporaryFontAssets final {
|
||||
public:
|
||||
TemporaryFontAssets() {
|
||||
char pattern[] = "/tmp/mocktail-font-assets-XXXXXX";
|
||||
const char* created = mkdtemp(pattern);
|
||||
if (created != nullptr) {
|
||||
root_ = created;
|
||||
}
|
||||
}
|
||||
|
||||
~TemporaryFontAssets() {
|
||||
if (!root_.empty()) {
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(root_, error);
|
||||
}
|
||||
}
|
||||
|
||||
const std::filesystem::path& root() const { return root_; }
|
||||
std::filesystem::path content() const { return root_ / "assets/content"; }
|
||||
|
||||
bool CreateFile(const std::filesystem::path& relative,
|
||||
const std::string& contents = {}) const {
|
||||
const std::filesystem::path path = root_ / relative;
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(path.parent_path(), error);
|
||||
if (error) {
|
||||
return false;
|
||||
}
|
||||
std::ofstream output(path, std::ios::binary);
|
||||
output << contents;
|
||||
return output.good();
|
||||
}
|
||||
|
||||
private:
|
||||
std::filesystem::path root_;
|
||||
};
|
||||
|
||||
TEST(RobloxTextFontResolverTest, UsesExactApkMappingAndSizeRatio) {
|
||||
TemporaryFontAssets assets;
|
||||
ASSERT_FALSE(assets.root().empty());
|
||||
ASSERT_TRUE(assets.CreateFile(
|
||||
"assets/android/fonts/font-mappings.json",
|
||||
R"([{"enum":13,"font":"PressStart2P-Regular.ttf","fromRbxFontRatio":0.9761678384}])"));
|
||||
ASSERT_TRUE(
|
||||
assets.CreateFile("assets/content/fonts/PressStart2P-Regular.ttf"));
|
||||
|
||||
const RobloxTextFontSelection selection =
|
||||
ResolveRobloxTextFont(13, assets.content().string());
|
||||
|
||||
EXPECT_TRUE(selection.exact_mapping);
|
||||
EXPECT_EQ(selection.primary_file,
|
||||
(assets.content() / "fonts/PressStart2P-Regular.ttf").string());
|
||||
EXPECT_FLOAT_EQ(selection.size_scale, 0.9761678384F);
|
||||
}
|
||||
|
||||
TEST(RobloxTextFontResolverTest, MatchesLegacyRbxKeyboardFallbacks) {
|
||||
TemporaryFontAssets assets;
|
||||
ASSERT_FALSE(assets.root().empty());
|
||||
ASSERT_TRUE(assets.CreateFile("assets/fonts/SourceSansPro-Bold.ttf"));
|
||||
|
||||
const RobloxTextFontSelection selection =
|
||||
ResolveRobloxTextFont(4, assets.content().string());
|
||||
|
||||
EXPECT_FALSE(selection.exact_mapping);
|
||||
EXPECT_EQ(selection.primary_file,
|
||||
(assets.root() / "assets/fonts/SourceSansPro-Bold.ttf").string());
|
||||
EXPECT_FLOAT_EQ(selection.size_scale, 0.795F);
|
||||
}
|
||||
|
||||
TEST(RobloxTextFontResolverTest, RejectsMappingPathTraversal) {
|
||||
TemporaryFontAssets assets;
|
||||
ASSERT_FALSE(assets.root().empty());
|
||||
ASSERT_TRUE(assets.CreateFile(
|
||||
"assets/android/fonts/font-mappings.json",
|
||||
R"([{"enum":13,"font":"../../outside.ttf","fromRbxFontRatio":1.0}])"));
|
||||
ASSERT_TRUE(assets.CreateFile("assets/fonts/SourceSansPro-Regular.ttf"));
|
||||
|
||||
const RobloxTextFontSelection selection =
|
||||
ResolveRobloxTextFont(13, assets.content().string());
|
||||
|
||||
EXPECT_FALSE(selection.exact_mapping);
|
||||
EXPECT_EQ(
|
||||
selection.primary_file,
|
||||
(assets.root() / "assets/fonts/SourceSansPro-Regular.ttf").string());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -25,8 +25,12 @@ class FakeBackend final : public RobloxTextInputJniBridgeBackend {
|
||||
last_area_y = session.area_y;
|
||||
last_area_width = session.area_width;
|
||||
last_area_height = session.area_height;
|
||||
last_font = session.font;
|
||||
last_font_size = session.font_size;
|
||||
last_text_input_type = session.text_input_type;
|
||||
last_multiline = session.multiline;
|
||||
last_text_wrapped = session.text_wrapped;
|
||||
last_return_key_type = session.return_key_type;
|
||||
last_manual_focus_release = session.manual_focus_release;
|
||||
active = true;
|
||||
std::fill(session.initial_text.begin(), session.initial_text.end(), '\0');
|
||||
@@ -124,8 +128,12 @@ class FakeBackend final : public RobloxTextInputJniBridgeBackend {
|
||||
int32_t last_area_y = 0;
|
||||
int32_t last_area_width = 0;
|
||||
int32_t last_area_height = 0;
|
||||
int32_t last_font = 0;
|
||||
float last_font_size = 0.0F;
|
||||
int32_t last_text_input_type = 0;
|
||||
int32_t last_return_key_type = 0;
|
||||
bool last_multiline = false;
|
||||
bool last_text_wrapped = false;
|
||||
bool last_manual_focus_release = false;
|
||||
std::string last_initial_text;
|
||||
std::vector<std::string> replaced_texts;
|
||||
@@ -607,6 +615,68 @@ TEST(RobloxTextInputJniBridgeTest,
|
||||
EXPECT_TRUE(bridge->Shutdown().ok());
|
||||
}
|
||||
|
||||
TEST(RobloxTextInputJniBridgeTest,
|
||||
InitialFocusPreservesCompleteRobloxTextPresentation) {
|
||||
jnivm::VM vm;
|
||||
auto backend = std::make_shared<FakeBackend>();
|
||||
std::unique_ptr<RobloxTextInputJniBridge> bridge;
|
||||
ASSERT_TRUE(
|
||||
RobloxTextInputJniBridge::CreateForTesting(&vm, backend, &bridge).ok());
|
||||
jnivm::RobloxTextInputShowRequest request = ShowRequest(43, "styled");
|
||||
request.info.font = 13;
|
||||
request.info.font_size = 21.0F;
|
||||
request.info.multiline = true;
|
||||
request.info.text_wrapped = true;
|
||||
request.info.return_key_type = 4;
|
||||
|
||||
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
|
||||
ASSERT_TRUE(backend->Pump());
|
||||
|
||||
EXPECT_EQ(backend->last_font, 13);
|
||||
EXPECT_FLOAT_EQ(backend->last_font_size, 21.0F);
|
||||
EXPECT_TRUE(backend->last_multiline);
|
||||
EXPECT_TRUE(backend->last_text_wrapped);
|
||||
EXPECT_EQ(backend->last_return_key_type, 4);
|
||||
EXPECT_TRUE(bridge->Shutdown().ok());
|
||||
}
|
||||
|
||||
TEST(RobloxTextInputJniBridgeTest,
|
||||
MissingGeometryIsRecoveredWithoutAPropertyCallback) {
|
||||
jnivm::VM vm;
|
||||
auto backend = std::make_shared<FakeBackend>();
|
||||
std::unique_ptr<RobloxTextInputJniBridge> bridge;
|
||||
ASSERT_TRUE(
|
||||
RobloxTextInputJniBridge::CreateForTesting(&vm, backend, &bridge).ok());
|
||||
jnivm::RobloxTextInputShowRequest request = ShowRequest(43, "editable");
|
||||
request.info.width = 0.0F;
|
||||
request.info.height = 0.0F;
|
||||
|
||||
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
|
||||
ASSERT_TRUE(backend->Pump());
|
||||
backend->calls.clear();
|
||||
backend->query_result.available = true;
|
||||
backend->query_result.info.x = 25.0F;
|
||||
backend->query_result.info.y = 50.0F;
|
||||
backend->query_result.info.width = 420.0F;
|
||||
backend->query_result.info.height = 36.0F;
|
||||
backend->query_result.info.font = 46;
|
||||
backend->query_result.info.font_size = 18.0F;
|
||||
|
||||
for (int pump = 0; pump < 8; ++pump) {
|
||||
ASSERT_TRUE(backend->Pump());
|
||||
}
|
||||
|
||||
EXPECT_EQ(backend->calls,
|
||||
(std::vector<std::string>{"query:1", "properties:1", "show:1"}));
|
||||
EXPECT_EQ(backend->last_properties.area_x, 25);
|
||||
EXPECT_EQ(backend->last_properties.area_y, 50);
|
||||
EXPECT_EQ(backend->last_properties.area_width, 420);
|
||||
EXPECT_EQ(backend->last_properties.area_height, 36);
|
||||
EXPECT_EQ(backend->last_properties.font, 46);
|
||||
EXPECT_FLOAT_EQ(backend->last_properties.font_size, 18.0F);
|
||||
EXPECT_TRUE(bridge->Shutdown().ok());
|
||||
}
|
||||
|
||||
TEST(RobloxTextInputJniBridgeTest,
|
||||
ShutdownClosesAppliedSessionBeforeReleasingBackend) {
|
||||
jnivm::VM vm;
|
||||
|
||||
Reference in new issue
Block a user