diff --git a/cmake/MocktailInput.cmake b/cmake/MocktailInput.cmake index 05c201c..5e231ed 100644 --- a/cmake/MocktailInput.cmake +++ b/cmake/MocktailInput.cmake @@ -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() diff --git a/include/runtime/roblox_text_display_state.h b/include/runtime/roblox_text_display_state.h index cac1bc4..adc5176 100644 --- a/include/runtime/roblox_text_display_state.h +++ b/include/runtime/roblox_text_display_state.h @@ -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; diff --git a/include/runtime/roblox_text_editor.h b/include/runtime/roblox_text_editor.h index bdce5d9..a620170 100644 --- a/include/runtime/roblox_text_editor.h +++ b/include/runtime/roblox_text_editor.h @@ -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; diff --git a/include/runtime/roblox_text_font_resolver.h b/include/runtime/roblox_text_font_resolver.h new file mode 100644 index 0000000..6abb2e2 --- /dev/null +++ b/include/runtime/roblox_text_font_resolver.h @@ -0,0 +1,26 @@ +#ifndef MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_ +#define MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_ + +#include +#include + +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_ diff --git a/src/runtime/roblox_text_display_state.cc b/src/runtime/roblox_text_display_state.cc index befdf11..89e9a83 100644 --- a/src/runtime/roblox_text_display_state.cc +++ b/src/runtime/roblox_text_display_state.cc @@ -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( diff --git a/src/runtime/roblox_text_editor.cc b/src/runtime/roblox_text_editor.cc index bdaefed..4085a2a 100644 --- a/src/runtime/roblox_text_editor.cc +++ b/src/runtime/roblox_text_editor.cc @@ -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(); diff --git a/src/runtime/roblox_text_font_resolver.cc b/src/runtime/roblox_text_font_resolver.cc new file mode 100644 index 0000000..418a7f3 --- /dev/null +++ b/src/runtime/roblox_text_font_resolver.cc @@ -0,0 +1,140 @@ +#include "runtime/roblox_text_font_resolver.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mocktail { +namespace runtime { +namespace { + +struct FontMapping { + std::string file; + float size_scale = 0.795F; +}; + +using FontMappings = std::unordered_map; + +std::mutex g_catalog_mutex; +std::unordered_map 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(); + const double encoded_ratio = ratio_value->get(); + const std::string encoded_font = font_value->get(); + 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(encoded_enum)] = { + encoded_font, static_cast(encoded_ratio)}; + } + return mappings; +} + +std::optional MappingFor( + int32_t font, const std::filesystem::path& asset_content_root) { + const std::string key = asset_content_root.lexically_normal().string(); + std::lock_guard 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() + : std::optional(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 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 diff --git a/src/runtime/roblox_text_input_jni_bridge.cc b/src/runtime/roblox_text_input_jni_bridge.cc index a602475..5c5d706 100644 --- a/src/runtime/roblox_text_input_jni_bridge.cc +++ b/src/runtime/roblox_text_input_jni_bridge.cc @@ -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* pending) { + if (pending == nullptr) { + return; + } + std::lock_guard 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 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 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 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; diff --git a/src/runtime/roblox_text_surface_overlay.cc b/src/runtime/roblox_text_surface_overlay.cc index 74a48a5..18da884 100644 --- a/src/runtime/roblox_text_surface_overlay.cc +++ b/src/runtime/roblox_text_surface_overlay.cc @@ -18,6 +18,8 @@ #include #include +#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 ResolveFontFiles() { +std::vector ResolveFontFiles( + const RobloxTextFontSelection& selection) { std::vector 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(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(std::min( byte_offset, static_cast(std::numeric_limits::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 candidate( static_cast(width * height * 4), 0); + const int frame_width = static_cast(width); + const int frame_height = static_cast(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(height) * 0.38F, 12.0F, 28.0F); - const std::vector font_files = ResolveFontFiles(); + const std::vector font_files = ResolveFontFiles(font_selection); std::vector 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(width); - const int frame_height = static_cast(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(std::min( + selection_begin_byte, + static_cast(std::numeric_limits::max()))); + const int range_length = static_cast(std::min( + range_bytes, + static_cast(std::numeric_limits::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); diff --git a/tests/roblox_text_display_state_test.cc b/tests/roblox_text_display_state_test.cc index bc0640c..9c45472 100644 --- a/tests/roblox_text_display_state_test.cc +++ b/tests/roblox_text_display_state_test.cc @@ -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 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(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 diff --git a/tests/roblox_text_editor_test.cc b/tests/roblox_text_editor_test.cc index 90c9211..63a9cbd 100644 --- a/tests/roblox_text_editor_test.cc +++ b/tests/roblox_text_editor_test.cc @@ -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); diff --git a/tests/roblox_text_font_resolver_test.cc b/tests/roblox_text_font_resolver_test.cc new file mode 100644 index 0000000..06211b2 --- /dev/null +++ b/tests/roblox_text_font_resolver_test.cc @@ -0,0 +1,102 @@ +#include "runtime/roblox_text_font_resolver.h" + +#include + +#include +#include +#include +#include + +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 diff --git a/tests/roblox_text_input_jni_bridge_test.cc b/tests/roblox_text_input_jni_bridge_test.cc index 5934c6f..fc8b2ff 100644 --- a/tests/roblox_text_input_jni_bridge_test.cc +++ b/tests/roblox_text_input_jni_bridge_test.cc @@ -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 replaced_texts; @@ -607,6 +615,68 @@ TEST(RobloxTextInputJniBridgeTest, EXPECT_TRUE(bridge->Shutdown().ok()); } +TEST(RobloxTextInputJniBridgeTest, + InitialFocusPreservesCompleteRobloxTextPresentation) { + jnivm::VM vm; + auto backend = std::make_shared(); + std::unique_ptr 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(); + std::unique_ptr 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{"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;