From 5e35c3305d9cabf22fbda1a6c532c8e94fd7ada9 Mon Sep 17 00:00:00 2001 From: Stardust <36493737+stardust3457@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:00:30 +0930 Subject: [PATCH] UI: Add the functionality to choose/create profiles with being able to select avatars from frimware loosely based off how ryujinx works (#91) --- external/sciterui | 2 +- lang/english/css/config.css | 179 +++ lang/english/css/profile_editor_dialog.css | 126 ++ .../css/profile_firmware_avatar_dialog.css | 119 ++ .../css/profile_image_selector_dialog.css | 41 + lang/english/html/profile_editor_dialog.html | 40 + .../html/profile_firmware_avatar_dialog.html | 42 + .../html/profile_image_selector_dialog.html | 26 + lang/english/html/system_config.html | 1 + lang/english/html/system_config_profiles.html | 7 + src/nxemu-module-spec/base.h | 2 +- src/nxemu-module-spec/operating_system.h | 20 + src/nxemu-os/nxemu-os.vcxproj | 2 + src/nxemu-os/nxemu-os.vcxproj.filters | 6 + src/nxemu-os/os_manager.cpp | 195 +++- src/nxemu-os/os_manager.h | 7 + src/nxemu-os/profile_image_writer.cpp | 86 ++ src/nxemu-os/profile_image_writer.h | 6 + src/nxemu/nxemu.vcxproj | 12 +- src/nxemu/nxemu.vcxproj.filters | 30 + src/nxemu/user_interface/about_dialog.cpp | 14 +- src/nxemu/user_interface/html_utils.cpp | 108 ++ src/nxemu/user_interface/html_utils.h | 14 + .../user_interface/sciter_main_window.cpp | 46 +- .../settings/profile_editor_dialog.cpp | 361 ++++++ .../settings/profile_editor_dialog.h | 49 + .../profile_firmware_avatar_dialog.cpp | 1039 +++++++++++++++++ .../settings/profile_firmware_avatar_dialog.h | 72 ++ .../profile_image_selector_dialog.cpp | 156 +++ .../settings/profile_image_selector_dialog.h | 43 + .../user_interface/settings/system_config.cpp | 9 + .../user_interface/settings/system_config.h | 2 + .../settings/system_config_profiles.cpp | 291 +++++ .../settings/system_config_profiles.h | 53 + .../user_interface/widgets/rom_browser.cpp | 4 +- 35 files changed, 3149 insertions(+), 61 deletions(-) create mode 100644 lang/english/css/profile_editor_dialog.css create mode 100644 lang/english/css/profile_firmware_avatar_dialog.css create mode 100644 lang/english/css/profile_image_selector_dialog.css create mode 100644 lang/english/html/profile_editor_dialog.html create mode 100644 lang/english/html/profile_firmware_avatar_dialog.html create mode 100644 lang/english/html/profile_image_selector_dialog.html create mode 100644 lang/english/html/system_config_profiles.html create mode 100644 src/nxemu-os/profile_image_writer.cpp create mode 100644 src/nxemu-os/profile_image_writer.h create mode 100644 src/nxemu/user_interface/html_utils.cpp create mode 100644 src/nxemu/user_interface/html_utils.h create mode 100644 src/nxemu/user_interface/settings/profile_editor_dialog.cpp create mode 100644 src/nxemu/user_interface/settings/profile_editor_dialog.h create mode 100644 src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.cpp create mode 100644 src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.h create mode 100644 src/nxemu/user_interface/settings/profile_image_selector_dialog.cpp create mode 100644 src/nxemu/user_interface/settings/profile_image_selector_dialog.h create mode 100644 src/nxemu/user_interface/settings/system_config_profiles.cpp create mode 100644 src/nxemu/user_interface/settings/system_config_profiles.h diff --git a/external/sciterui b/external/sciterui index db5a4d18..c2cdf8fa 160000 --- a/external/sciterui +++ b/external/sciterui @@ -1 +1 @@ -Subproject commit db5a4d1840cd4fdda67cc8ef3f37597be26190ab +Subproject commit c2cdf8fa3a4c5807a6178a88799844e307af1101 diff --git a/lang/english/css/config.css b/lang/english/css/config.css index 152b9894..4b125969 100644 --- a/lang/english/css/config.css +++ b/lang/english/css/config.css @@ -250,6 +250,185 @@ label:disabled { margin-bottom: 0.75rem; } +.page_content.profiles-page +{ + flow: vertical; + width: 100%%; + height: 100%%; + padding: 1em; +} + +.active-profile-label +{ + margin: 0 0 0.65rem 0; + color: #eff0f1; + font-size: 0.9rem; +} + +.profile-grid +{ + flow: horizontal-wrap; + border: 1px solid #55575a; + border-radius: 5px; + background: #1e1e1e; + padding: 0.5rem; + width: 100%%; + height: *; + overflow-y: auto; + vertical-align: top; +} + +.profile-tile +{ + position: relative; + width: 96px; + margin: 0.35rem; + padding: 0.25rem; + border: 2px solid transparent; + border-radius: 5px; + flow: vertical; + horizontal-align: center; + cursor: pointer; + behavior: clickable; +} + +.profile-tile:hover +{ + background: rgba(255, 255, 255, 0.06); +} + +.profile-tile.current +{ + background: rgba(61, 174, 233, 0.22); + border: 2px solid #3daee9; +} + +.profile-tile.current > .profile-tile-name +{ + color: #ffffff; + font-weight: 600; +} + +.profile-tile-avatar +{ + width: 96px; + height: 96px; + border-radius: 4px; + background: #111111; + overflow: hidden; + foreground-repeat: no-repeat; + foreground-position: 50% 50%; + foreground-size: 100% 100%; +} + +.profile-tile-avatar img +{ + display: block; + width: 96px; + height: 96px; + background: transparent; + foreground-repeat: no-repeat; + foreground-position: 50% 50%; + foreground-size: 100% 100%; +} + +.profile-tile-name +{ + width: 90px; + margin-top: 0.35rem; + text-align: center; + color: #eff0f1; + font-size: 0.85rem; + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-tile-edit +{ + position: absolute; + top: 0.35rem; + right: 0.35rem; + width: 24px; + height: 24px; + border-radius: 12px; + background: #2b2f32; + border: none; + color: #ffffff; + padding: 0; + z-index: 2; + opacity: 0; + visibility: hidden; + behavior: clickable; + cursor: pointer; + overflow: hidden; +} + +.profile-tile-edit svg +{ + position: absolute; + left: 5px; + top: 5px; + width: 14px; + height: 14px; +} + +.profile-tile-edit path +{ + fill: none; + stroke: currentColor; +} + +.profile-tile:hover > .profile-tile-edit +{ + opacity: 1; + visibility: visible; +} + +.profile-tile-edit:hover +{ + background: #3a4045; +} + +.profile-tile-add +{ + position: relative; + width: 96px; + height: 118px; + margin: 0.35rem; + border-radius: 5px; + background: #2b2f32; + cursor: pointer; + behavior: clickable; +} + +.profile-tile-add-button +{ + position: absolute; + left: 27px; + top: 38px; + width: 42px; + height: 42px; + border-radius: 21px; + background: #2b2f32; + border: 1px solid #55575a; + color: #eff0f1; + font-size: 1.6rem; + font-family: var(--font-ui); + line-height: 40px; + text-align: center; + padding: 0; + overflow: hidden; + behavior: clickable; + cursor: pointer; +} + +.profile-tile-add-button:hover +{ + background: #3a4045; + border: 1px solid #3cafe9; +} + .hotkeys-table { width: 100%%; diff --git a/lang/english/css/profile_editor_dialog.css b/lang/english/css/profile_editor_dialog.css new file mode 100644 index 00000000..be111659 --- /dev/null +++ b/lang/english/css/profile_editor_dialog.css @@ -0,0 +1,126 @@ +.profile-editor-body +{ + padding: 0.75rem 0.5rem 0.25rem; + min-width: 32rem; + color: #eff0f1; + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 0.9rem; +} + +.profile-detail-layout +{ + flow: horizontal; + width: 100%; +} + +.profile-detail-fields +{ + width: *; + min-width: 22rem; + flow: vertical; +} + +.profile-field +{ + flow: vertical; + width: *; + margin: 0 0 0.85rem 0; +} + +.profile-field > label +{ + margin: 0 0 0.35rem 0; + color: #eff0f1; +} + +.profile-field > input +{ + display: block; + width: *; + height: 2rem; + min-height: 2rem; + max-height: 2rem; + box-sizing: border-box; + color: #ffffff; + background: #2f2f31; + border: 1px solid #6a6e73; + border-radius: 0.35rem; + padding: 0 0.6rem; + margin: 0; + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 0.875rem; + line-height: 2rem; + vertical-align: middle; + font-rendering-mode: snap-pixel; + text-selection-caret-color: #ffffff; + text-selection-color: #ffffff; + text-selection-background-color: #3daee9; +} + +.profile-field > input:focus, +.profile-field > input:active +{ + border-color: #3daee9; + background: #3c3c3c; + text-selection-caret-color: #ffffff; +} + +.profile-field > input:hover +{ + background: #3c3c3c; +} + +.profile-field > input:disabled, +.profile-field > input[readonly] +{ + background: #2a2a2c; + color: #d0d0d0; + border-color: #55575a; + text-selection-caret-color: transparent; +} + +.profile-field > input:disabled:hover, +.profile-field > input[readonly]:hover +{ + background: #2a2a2c; +} + +.profile-detail-image +{ + margin-left: 1rem; +} + +.profile-detail-avatar +{ + width: 96px; + height: 96px; + border: 1px solid #55575a; + border-radius: 4px; + background: #111111; + overflow: hidden; + foreground-repeat: no-repeat; + foreground-position: 50% 50%; + foreground-size: 100% 100%; +} + +.profile-detail-avatar img +{ + display: block; + width: 96px; + height: 96px; + background: transparent; + foreground-repeat: no-repeat; + foreground-position: 50% 50%; + foreground-size: 100% 100%; +} + +.profile-editor-footer +{ + flow: horizontal; + horizontal-align: right; +} + +.profile-editor-footer > button +{ + margin-left: 0.4rem; +} diff --git a/lang/english/css/profile_firmware_avatar_dialog.css b/lang/english/css/profile_firmware_avatar_dialog.css new file mode 100644 index 00000000..eb198f77 --- /dev/null +++ b/lang/english/css/profile_firmware_avatar_dialog.css @@ -0,0 +1,119 @@ +html +{ + overflow: hidden; +} + +body +{ + flow: vertical; +} + +.firmware-avatar-body +{ + padding: 0.5rem 0.25rem; + min-width: 32rem; + height: 21rem; + color: #eff0f1; + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 0.9rem; +} + +.firmware-avatar-grid +{ + flow: horizontal-wrap; + overflow-y: auto; + height: 100%; + width: 100%; + box-sizing: border-box; + padding: 0.25rem; + border: 1px solid #55575a; + background: #121212; + horizontal-align: center; +} + +.firmware-avatar-tile +{ + width: 72px; + height: 72px; + margin: 5px; + border: 2px solid transparent; + border-radius: 4px; + background: #ffffff; + overflow: hidden; + cursor: pointer; +} + +.firmware-avatar-tile:hover +{ + border-color: #3cafe9; +} + +.firmware-avatar-tile.selected +{ + border-color: #3cafe9; +} + +.firmware-avatar-tile img +{ + width: 72px; + height: 72px; + display: block; + foreground-repeat: no-repeat; + foreground-position: 50% 50%; + foreground-size: contain; +} + +.firmware-avatar-empty +{ + padding: 2rem 1rem; + text-align: center; + color: #b0b3b5; +} + +.firmware-avatar-footer +{ + flow: horizontal; + vertical-align: middle; +} + +.firmware-avatar-footer > #firmwareAvatarBack +{ + margin-right: *; +} + +.firmware-avatar-actions +{ + flow: horizontal; + vertical-align: middle; +} + +.firmware-avatar-actions > * +{ + margin-left: 0.4rem; +} + +.firmware-avatar-colors +{ + flow: horizontal; + vertical-align: middle; +} + +.firmware-avatar-swatch +{ + width: 1.35rem; + height: 1.35rem; + margin-right: 0.25rem; + border: 2px solid #55575a; + border-radius: 3px; + cursor: pointer; +} + +.firmware-avatar-swatch:hover +{ + border-color: #3cafe9; +} + +.firmware-avatar-swatch.selected +{ + border-color: #3cafe9; +} diff --git a/lang/english/css/profile_image_selector_dialog.css b/lang/english/css/profile_image_selector_dialog.css new file mode 100644 index 00000000..d41f9566 --- /dev/null +++ b/lang/english/css/profile_image_selector_dialog.css @@ -0,0 +1,41 @@ +.profile-image-selector-body +{ + padding: 0.75rem 0.25rem 0.25rem; + min-width: 28rem; + color: #eff0f1; + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 0.9rem; +} + +.profile-image-selector-body p +{ + margin: 0; + line-height: 1.4; + white-space: normal; +} + +.profile-image-selector-footer +{ + flow: horizontal; + vertical-align: middle; +} + +.profile-image-selector-footer > #profileImageBack +{ + margin-right: *; +} + +.profile-image-selector-actions +{ + flow: horizontal; +} + +.profile-image-selector-actions > button +{ + margin-left: 0.4rem; +} + +#profileImageFirmware:disabled +{ + opacity: 0.45; +} diff --git a/lang/english/html/profile_editor_dialog.html b/lang/english/html/profile_editor_dialog.html new file mode 100644 index 00000000..f7263a04 --- /dev/null +++ b/lang/english/html/profile_editor_dialog.html @@ -0,0 +1,40 @@ + + + + + + Edit Profile + +
+ Edit Profile + + + +
+ +
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+ + + diff --git a/lang/english/html/profile_firmware_avatar_dialog.html b/lang/english/html/profile_firmware_avatar_dialog.html new file mode 100644 index 00000000..98bd7a99 --- /dev/null +++ b/lang/english/html/profile_firmware_avatar_dialog.html @@ -0,0 +1,42 @@ + + + + + + Select Firmware Avatar + +
+ Select Firmware Avatar + + + +
+ +
+
+ +
+ + + diff --git a/lang/english/html/profile_image_selector_dialog.html b/lang/english/html/profile_image_selector_dialog.html new file mode 100644 index 00000000..abd03089 --- /dev/null +++ b/lang/english/html/profile_image_selector_dialog.html @@ -0,0 +1,26 @@ + + + + + + Profile Image Selection + +
+ Choose a profile Image + + + +
+ +
+

You may import a custom profile image, or select an avatar from system firmware

+
+ + + diff --git a/lang/english/html/system_config.html b/lang/english/html/system_config.html index 000e7e45..5d774476 100644 --- a/lang/english/html/system_config.html +++ b/lang/english/html/system_config.html @@ -146,6 +146,7 @@ PageNav.page_tabs page.active { +
diff --git a/lang/english/html/system_config_profiles.html b/lang/english/html/system_config_profiles.html new file mode 100644 index 00000000..f82ab00e --- /dev/null +++ b/lang/english/html/system_config_profiles.html @@ -0,0 +1,7 @@ + + +
Active profile:
+
+
+
+
diff --git a/src/nxemu-module-spec/base.h b/src/nxemu-module-spec/base.h index 174b5b17..45c1d32f 100644 --- a/src/nxemu-module-spec/base.h +++ b/src/nxemu-module-spec/base.h @@ -29,7 +29,7 @@ enum MODULE_LOADER_SPECS_VERSION = 0x0123, MODULE_VIDEO_SPECS_VERSION = 0x011A, MODULE_CPU_SPECS_VERSION = 0x0111, - MODULE_OPERATING_SYSTEM_SPECS_VERSION = 0x0118, + MODULE_OPERATING_SYSTEM_SPECS_VERSION = 0x0119, }; enum MODULE_TYPE : uint16_t diff --git a/src/nxemu-module-spec/operating_system.h b/src/nxemu-module-spec/operating_system.h index 549391f0..016a56c2 100644 --- a/src/nxemu-module-spec/operating_system.h +++ b/src/nxemu-module-spec/operating_system.h @@ -646,6 +646,19 @@ nxinterface IButtonMappingList virtual void Release() = 0; }; +enum +{ + HOST_PROFILE_UUID_SIZE = 16, + HOST_PROFILE_USERNAME_SIZE = 32, + HOST_PROFILE_MAX_USERS = 8, +}; + +typedef struct HostProfileInfo +{ + uint8_t uuid[HOST_PROFILE_UUID_SIZE]; + char username[HOST_PROFILE_USERNAME_SIZE + 1]; +} HostProfileInfo; + nxinterface IOperatingSystem { virtual bool Initialize() = 0; @@ -685,6 +698,13 @@ nxinterface IOperatingSystem virtual void SetFrontendApplets(ICabinetFrontendApplet * cabinet, IControllerFrontendApplet * controller, IErrorFrontendApplet * error, IMiiEditFrontendApplet * mii_edit, IParentalControlsFrontendApplet * parental_controls, IPhotoViewerFrontendApplet * photo_viewer, IProfileSelectFrontendApplet * profile_select, ISoftwareKeyboardFrontendApplet * software_keyboard, IWebBrowserFrontendApplet * web_browser) = 0; virtual void SetPlayerButtonState(uint32_t player_index, uint32_t button_ordinal, bool pressed) = 0; virtual void SetPlayerAnalogState(uint32_t player_index, uint32_t stick_index, float x, float y) = 0; + virtual uint32_t GetProfileCount() const = 0; + virtual bool GetProfile(uint32_t index, HostProfileInfo * profile) const = 0; + virtual bool CreateProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const char * username_utf8, HostProfileInfo * out_profile) = 0; + virtual bool RenameProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const char * username_utf8) = 0; + virtual bool RemoveProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE]) = 0; + virtual bool SetProfileImage(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const uint8_t * image_data, uint32_t image_size) = 0; + virtual bool GetProfileImagePath(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], char * out_path, uint32_t out_path_size) const = 0; }; EXPORT IOperatingSystem * CALL CreateOperatingSystem(ISystemModules & modules); diff --git a/src/nxemu-os/nxemu-os.vcxproj b/src/nxemu-os/nxemu-os.vcxproj index 559b77d0..8d415405 100644 --- a/src/nxemu-os/nxemu-os.vcxproj +++ b/src/nxemu-os/nxemu-os.vcxproj @@ -482,6 +482,7 @@ + @@ -992,6 +993,7 @@ + diff --git a/src/nxemu-os/nxemu-os.vcxproj.filters b/src/nxemu-os/nxemu-os.vcxproj.filters index d2c12992..a168ce88 100644 --- a/src/nxemu-os/nxemu-os.vcxproj.filters +++ b/src/nxemu-os/nxemu-os.vcxproj.filters @@ -2043,6 +2043,9 @@ Header Files\core\hle\service\ldn + + Header Files + @@ -3409,5 +3412,8 @@ Source Files\core\hle\service\ldn + + Source Files + \ No newline at end of file diff --git a/src/nxemu-os/os_manager.cpp b/src/nxemu-os/os_manager.cpp index 23cfbc19..757c70eb 100644 --- a/src/nxemu-os/os_manager.cpp +++ b/src/nxemu-os/os_manager.cpp @@ -1,7 +1,9 @@ #include "os_manager.h" +#include "profile_image_writer.h" #include "core/core_timing.h" #include "core/cpu_manager.h" #include "core/hle/kernel/k_process.h" +#include "core/hle/service/acc/profile_manager.h" #include "core/hle/service/am/applet_manager.h" #include "core/hle/service/am/frontend/applets.h" #include "core/hle/service/filesystem/filesystem.h" @@ -9,16 +11,21 @@ #include "os_settings.h" #include "os_settings_identifiers.h" #include "yuzu_audio_core/sink/sink_details.h" +#include "yuzu_common/fs/path_util.h" #include "yuzu_common/settings.h" +#include "yuzu_common/string_util.h" #include "yuzu_hid_core/frontend/emulated_controller.h" #include "yuzu_hid_core/hid_core.h" #include "yuzu_input_common/drivers/keyboard.h" #include "yuzu_input_common/drivers/virtual_gamepad.h" #include "yuzu_input_common/main.h" #include +#include namespace { + constexpr char ACC_SAVE_AVATORS_BASE_PATH[] = "system/save/8000000000000010/su/avators"; + class IButtonMappingListImpl : public IButtonMappingList { public: @@ -88,6 +95,37 @@ namespace std::vector m_indices; std::vector m_params; }; + + bool FillHostProfileInfo(const Service::Account::ProfileManager & manager, std::size_t index, HostProfileInfo * out_profile) + { + if (out_profile == nullptr) + { + return false; + } + + const auto uuid = manager.GetUser(index); + if (!uuid) + { + return false; + } + + Service::Account::ProfileBase profile{}; + if (!manager.GetProfileBase(*uuid, profile)) + { + return false; + } + + std::memcpy(out_profile->uuid, profile.user_uuid.uuid.data(), HOST_PROFILE_UUID_SIZE); + const std::string username = Common::StringFromFixedZeroTerminatedBuffer((const char *)profile.username.data(), profile.username.size()); + std::memset(out_profile->username, 0, sizeof(out_profile->username)); + std::strncpy(out_profile->username, username.c_str(), HOST_PROFILE_USERNAME_SIZE); + return true; + } + + std::filesystem::path ProfileImageFilesystemPath(const Common::UUID & uuid) + { + return Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir) / ACC_SAVE_AVATORS_BASE_PATH / (uuid.FormattedString() + ".jpg"); + } } extern IModuleSettings * g_settings; @@ -416,4 +454,159 @@ void OSManager::SetPlayerAnalogState(uint32_t player_index, uint32_t stick_index return; } virtual_gamepad->SetStickPosition(player_index, static_cast(stick_index), x, y); -} \ No newline at end of file +} + +uint32_t OSManager::GetProfileCount() const +{ + Service::Account::ProfileManager manager; + return (uint32_t)manager.GetUserCount(); +} + +bool OSManager::GetProfile(uint32_t index, HostProfileInfo * out_profile) const +{ + Service::Account::ProfileManager manager; + return FillHostProfileInfo(manager, index, out_profile); +} + +bool OSManager::CreateProfile(const uint8_t uuid_bytes[HOST_PROFILE_UUID_SIZE], const char * username_utf8, HostProfileInfo * out_profile) +{ + if (uuid_bytes == nullptr || username_utf8 == nullptr || username_utf8[0] == '\0') + { + return false; + } + + Service::Account::ProfileManager manager; + if (manager.GetUserCount() >= Service::Account::MAX_USERS) + { + return false; + } + + std::array uuid_array{}; + std::memcpy(uuid_array.data(), uuid_bytes, HOST_PROFILE_UUID_SIZE); + const Common::UUID uuid{uuid_array}; + if (uuid.IsInvalid() || manager.UserExists(uuid)) + { + return false; + } + + if (manager.CreateNewUser(uuid, std::string(username_utf8)).IsError()) + { + return false; + } + + manager.WriteUserSaveFile(); + if (out_profile == nullptr) + { + return true; + } + + const auto index = manager.GetUserIndex(uuid); + return index && FillHostProfileInfo(manager, *index, out_profile); +} + +bool OSManager::RenameProfile(const uint8_t uuid_bytes[HOST_PROFILE_UUID_SIZE], const char * username_utf8) +{ + if (uuid_bytes == nullptr || username_utf8 == nullptr || username_utf8[0] == '\0') + { + return false; + } + + std::array uuid_array{}; + std::memcpy(uuid_array.data(), uuid_bytes, HOST_PROFILE_UUID_SIZE); + const Common::UUID uuid{uuid_array}; + + Service::Account::ProfileManager manager; + Service::Account::ProfileBase profile{}; + if (!manager.GetProfileBase(uuid, profile)) + { + return false; + } + + const std::string username(username_utf8); + profile.username.fill(0); + std::copy_n(username.begin(), std::min(username.size(), profile.username.size()), profile.username.begin()); + + if (!manager.SetProfileBase(uuid, profile)) + { + return false; + } + + manager.WriteUserSaveFile(); + return true; +} + +bool OSManager::RemoveProfile(const uint8_t uuid_bytes[HOST_PROFILE_UUID_SIZE]) +{ + if (uuid_bytes == nullptr) + { + return false; + } + + std::array uuid_array{}; + std::memcpy(uuid_array.data(), uuid_bytes, HOST_PROFILE_UUID_SIZE); + const Common::UUID uuid{uuid_array}; + + Service::Account::ProfileManager manager; + if (manager.GetUserCount() < 2 || !manager.RemoveUser(uuid)) + { + return false; + } + + manager.WriteUserSaveFile(); + return true; +} + +bool OSManager::SetProfileImage(const uint8_t uuid_bytes[HOST_PROFILE_UUID_SIZE], const uint8_t * image_data, uint32_t image_size) +{ + if (uuid_bytes == nullptr || image_data == nullptr || image_size == 0) + { + return false; + } + + std::array uuid_array{}; + std::memcpy(uuid_array.data(), uuid_bytes, HOST_PROFILE_UUID_SIZE); + const Common::UUID uuid{uuid_array}; + + Service::Account::ProfileManager manager; + if (!manager.UserExists(uuid)) + { + return false; + } + + return WriteProfileJpegFromMemory(image_data, image_size, ProfileImageFilesystemPath(uuid)); +} + +bool OSManager::GetProfileImagePath(const uint8_t uuid_bytes[HOST_PROFILE_UUID_SIZE], char * out_path, uint32_t out_path_size) const +{ + if (uuid_bytes == nullptr || out_path == nullptr || out_path_size == 0) + { + return false; + } + + std::array uuid_array{}; + std::memcpy(uuid_array.data(), uuid_bytes, HOST_PROFILE_UUID_SIZE); + const Common::UUID uuid{uuid_array}; + + Service::Account::ProfileManager manager; + if (!manager.UserExists(uuid)) + { + return false; + } + + const std::filesystem::path imagePath = ProfileImageFilesystemPath(uuid); + std::error_code ec; + if (!std::filesystem::exists(imagePath, ec) || ec) + { + out_path[0] = '\0'; + return true; + } + + const std::string path = Common::FS::PathToUTF8String(imagePath); + if (path.size() + 1 > out_path_size) + { + return false; + } + + std::memcpy(out_path, path.c_str(), path.size() + 1); + return true; +} diff --git a/src/nxemu-os/os_manager.h b/src/nxemu-os/os_manager.h index 00a5b76a..a1ed7ae3 100644 --- a/src/nxemu-os/os_manager.h +++ b/src/nxemu-os/os_manager.h @@ -51,6 +51,13 @@ public: void SetFrontendApplets(ICabinetFrontendApplet * cabinet, IControllerFrontendApplet * controller, IErrorFrontendApplet * error, IMiiEditFrontendApplet * mii_edit, IParentalControlsFrontendApplet * parental_controls, IPhotoViewerFrontendApplet * photo_viewer, IProfileSelectFrontendApplet * profile_select, ISoftwareKeyboardFrontendApplet * software_keyboard, IWebBrowserFrontendApplet * web_browser) override; void SetPlayerButtonState(uint32_t player_index, uint32_t button_ordinal, bool pressed) override; void SetPlayerAnalogState(uint32_t player_index, uint32_t stick_index, float x, float y) override; + uint32_t GetProfileCount() const override; + bool GetProfile(uint32_t index, HostProfileInfo * out_profile) const override; + bool CreateProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const char * username_utf8, HostProfileInfo * out_profile) override; + bool RenameProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const char * username_utf8) override; + bool RemoveProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE]) override; + bool SetProfileImage(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const uint8_t * image_data, uint32_t image_size) override; + bool GetProfileImagePath(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], char * out_path, uint32_t out_path_size) const override; private: OSManager() = delete; diff --git a/src/nxemu-os/profile_image_writer.cpp b/src/nxemu-os/profile_image_writer.cpp new file mode 100644 index 00000000..6db4cecc --- /dev/null +++ b/src/nxemu-os/profile_image_writer.cpp @@ -0,0 +1,86 @@ +#include "profile_image_writer.h" + +#include "yuzu_common/fs/file.h" +#include "yuzu_common/fs/fs.h" +#include "yuzu_common/fs/path_util.h" +#include "yuzu_common/stb.h" +namespace +{ +constexpr int ProfileDimension = 256; +constexpr std::size_t ProfilePixelCount = static_cast(ProfileDimension) * ProfileDimension; + +void JpgToMemory(void * context, void * data, int len) +{ + std::vector * jpg = (std::vector *)context; + const unsigned char * bytes = (const unsigned char *)data; + jpg->insert(jpg->end(), bytes, bytes + len); +} + +bool WriteJpegRgb(const uint8_t * rgb, const std::filesystem::path & destination) +{ + if (!Common::FS::CreateParentDirs(destination)) + { + return false; + } + + std::error_code ec; + if (std::filesystem::exists(destination, ec)) + { + if (!Common::FS::RemoveFile(destination)) + { + return false; + } + } + + std::vector jpeg; + if (!stbi_write_jpg_to_func(JpgToMemory, &jpeg, ProfileDimension, ProfileDimension, STBI_rgb, rgb, 100)) + { + return false; + } + + Common::FS::IOFile file(destination, Common::FS::FileAccessMode::Write, Common::FS::FileType::BinaryFile); + if (!file.IsOpen() || !file.SetSize(jpeg.size()) || file.Write(jpeg) != jpeg.size()) + { + return false; + } + return true; +} +} // namespace + +bool WriteProfileJpegFromMemory(const uint8_t * data, size_t size, const std::filesystem::path & destination) +{ + if (data == nullptr || size == 0) + { + return false; + } + + int32_t width = 0, height = 0, channels = 0; + unsigned char * pixels = stbi_load_from_memory(data, static_cast(size), &width, &height, &channels, STBI_rgb); + if (pixels == nullptr || width <= 0 || height <= 0) + { + if (pixels != nullptr) + { + stbi_image_free(pixels); + } + return false; + } + + std::vector resized(ProfilePixelCount * 3); + if (width != ProfileDimension || height != ProfileDimension) + { + if (!stbir_resize_uint8(pixels, width, height, 0, resized.data(), ProfileDimension, ProfileDimension, 0, STBI_rgb)) + { + stbi_image_free(pixels); + return false; + } + stbi_image_free(pixels); + pixels = nullptr; + } + else + { + std::memcpy(resized.data(), pixels, resized.size()); + stbi_image_free(pixels); + pixels = nullptr; + } + return WriteJpegRgb(resized.data(), destination); +} diff --git a/src/nxemu-os/profile_image_writer.h b/src/nxemu-os/profile_image_writer.h new file mode 100644 index 00000000..d3e5f27e --- /dev/null +++ b/src/nxemu-os/profile_image_writer.h @@ -0,0 +1,6 @@ +#pragma once + +#include +#include + +bool WriteProfileJpegFromMemory(const uint8_t * data, size_t size, const std::filesystem::path & destination); diff --git a/src/nxemu/nxemu.vcxproj b/src/nxemu/nxemu.vcxproj index 40d4b1ab..27ac847f 100644 --- a/src/nxemu/nxemu.vcxproj +++ b/src/nxemu/nxemu.vcxproj @@ -24,7 +24,7 @@ - $(SolutionDir)external\fmt\include;$(SolutionDir)external\vulkan-headers\include;$(SolutionDir)external\sciterui\include;$(SolutionDir)src\3rd_party\discord-rpc\include;%(AdditionalIncludeDirectories) + $(SolutionDir)src\3rd_party\stb;$(SolutionDir)external\fmt\include;$(SolutionDir)external\vulkan-headers\include;$(SolutionDir)external\sciterui\include;$(SolutionDir)src\3rd_party\discord-rpc\include;%(AdditionalIncludeDirectories) true NOMINMAX;%(PreprocessorDefinitions) @@ -43,6 +43,7 @@ + @@ -50,12 +51,16 @@ + + + + @@ -69,6 +74,7 @@ + @@ -77,6 +83,9 @@ + + + @@ -84,6 +93,7 @@ + diff --git a/src/nxemu/nxemu.vcxproj.filters b/src/nxemu/nxemu.vcxproj.filters index 2b187790..bd141406 100644 --- a/src/nxemu/nxemu.vcxproj.filters +++ b/src/nxemu/nxemu.vcxproj.filters @@ -111,6 +111,21 @@ Source Files\applets + + Source Files\user_interface\settings + + + Source Files\user_interface + + + Source Files\user_interface\settings + + + Source Files\user_interface\settings + + + Source Files\user_interface\settings + Source Files\user_interface @@ -191,6 +206,21 @@ Header Files\applets + + Header Files\user_interface\settings + + + Header Files\user_interface + + + Header Files\user_interface\settings + + + Header Files\user_interface\settings + + + Header Files\user_interface\settings + Header Files\user_interface diff --git a/src/nxemu/user_interface/about_dialog.cpp b/src/nxemu/user_interface/about_dialog.cpp index 74c36765..bc20593f 100644 --- a/src/nxemu/user_interface/about_dialog.cpp +++ b/src/nxemu/user_interface/about_dialog.cpp @@ -1,4 +1,5 @@ #include "about_dialog.h" +#include "user_interface/html_utils.h" #include #include @@ -8,19 +9,6 @@ #include -namespace -{ - -void AttachClickHandler(ISciterUI & sciterUI, const SciterElement & element, IClickSink * sink) -{ - if (element.IsValid()) - { - sciterUI.AttachHandler(element, IID_ICLICKSINK, sink); - } -} - -} // namespace - AboutDialog::AboutDialog(ISciterUI & sciterUI) : m_sciterUI(sciterUI), m_window(nullptr) diff --git a/src/nxemu/user_interface/html_utils.cpp b/src/nxemu/user_interface/html_utils.cpp new file mode 100644 index 00000000..64aa0dd1 --- /dev/null +++ b/src/nxemu/user_interface/html_utils.cpp @@ -0,0 +1,108 @@ +#include "html_utils.h" +#include +#include +#include +#include +#include +#include + +namespace +{ +const char * SniffImageMime(const uint8_t * data, size_t size) +{ + if (size >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47) + { + return "image/png"; + } + if (size >= 2 && data[0] == 0xff && data[1] == 0xd8) + { + return "image/jpeg"; + } + if (size >= 3 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F') + { + return "image/gif"; + } + return "image/jpeg"; +} + +} // namespace + +std::string HtmlEscape(const std::string & text) +{ + std::string out; + out.reserve(text.size()); + for (const char ch : text) + { + switch (ch) + { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + case '"': out += """; break; + case '\'': out += "'"; break; + default: out.push_back(ch); break; + } + } + return out; +} + +std::string ImageDataUri(const uint8_t * data, size_t size) +{ + if (data == nullptr || size == 0) + { + return {}; + } + return stdstr_f("data:%s;base64,%s", SniffImageMime(data, size), base64_encode(data, size).c_str()); +} + +std::string ImageDataUriFromFile(const char * path) +{ + if (path == nullptr || path[0] == '\0') + { + return {}; + } + + std::ifstream file(path, std::ios::binary); + if (!file) + { + return {}; + } + + const std::vector data((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + return ImageDataUri(data.data(), data.size()); +} + +void AttachClickHandler(ISciterUI & sciterUI, const SciterElement & element, IClickSink * sink) +{ + if (element.IsValid()) + { + sciterUI.AttachHandler(element, IID_ICLICKSINK, sink); + } +} + +void SetElementEnabled(const SciterElement & root, const char * id, bool enabled) +{ + SciterElement el(root.GetElementByID(id)); + if (!el.IsValid()) + { + return; + } + if (enabled) + { + el.SetState(0, SciterElement::STATE_DISABLED, true); + } + else + { + el.SetState(SciterElement::STATE_DISABLED, 0, true); + } +} + +void SetElementVisible(const SciterElement & root, const char * id, bool visible) +{ + SciterElement el(root.GetElementByID(id)); + if (!el.IsValid()) + { + return; + } + el.SetStyleAttribute("display", visible ? "" : "none"); +} diff --git a/src/nxemu/user_interface/html_utils.h b/src/nxemu/user_interface/html_utils.h new file mode 100644 index 00000000..2c7b34af --- /dev/null +++ b/src/nxemu/user_interface/html_utils.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +__interface ISciterUI; +__interface IClickSink; +class SciterElement; + +std::string HtmlEscape(const std::string & text); +std::string ImageDataUri(const uint8_t * data, size_t size); +std::string ImageDataUriFromFile(const char * path); +void AttachClickHandler(ISciterUI & sciterUI, const SciterElement & element, IClickSink * sink); +void SetElementEnabled(const SciterElement & root, const char * id, bool enabled); +void SetElementVisible(const SciterElement & root, const char * id, bool visible); diff --git a/src/nxemu/user_interface/sciter_main_window.cpp b/src/nxemu/user_interface/sciter_main_window.cpp index 0a2a2235..da16ec28 100644 --- a/src/nxemu/user_interface/sciter_main_window.cpp +++ b/src/nxemu/user_interface/sciter_main_window.cpp @@ -3,9 +3,9 @@ #include "settings/system_config.h" #include "settings/ui_settings.h" #include "user_interface/about_dialog.h" +#include "user_interface/html_utils.h" #include "user_interface/key_mappings.h" #include "user_interface/notification.h" -#include #include #include #include @@ -111,17 +111,7 @@ void LoadImageToElement(SciterElement elem, const std::vector & data) } if (!data.empty()) { - const char * mime = "image/png"; - if (data.size() >= 2 && data[0] == 0xff && data[1] == 0xd8) - { - mime = "image/jpeg"; - } - else if (data.size() >= 4 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F') - { - mime = "image/gif"; - } - const std::string uri = stdstr_f("data:%s;base64,%s", mime, base64_encode(data.data(), data.size()).c_str()); - elem.SetAttribute("src", uri.c_str()); + elem.SetAttribute("src", ImageDataUri(data.data(), data.size()).c_str()); } elem.SetStyleAttribute("display", data.empty() ? "none" : "block"); } @@ -165,34 +155,6 @@ void UpdateLoadingProgressBar(SciterElement & fillEl, bool indeterminate, int wi } } -std::string HtmlEscapeForHtmlContent(const std::string & s) -{ - std::string out; - out.reserve(s.size()); - for (const char c : s) - { - switch (c) - { - case '&': - out += "&"; - break; - case '<': - out += "<"; - break; - case '>': - out += ">"; - break; - case '"': - out += """; - break; - default: - out += c; - break; - } - } - return out; -} - std::string GetGameTitleLoadingHtml(ISystemloader & loader, const char * verb) { IRomInfoPtr info(loader.LoadedRomInfo()); @@ -210,7 +172,7 @@ std::string GetGameTitleLoadingHtml(ISystemloader & loader, const char * verb) { return stdstr_f("%s ...", verb); } - const std::string titleEsc = HtmlEscapeForHtmlContent(std::string(buf.data())); + const std::string titleEsc = HtmlEscape(std::string(buf.data())); return stdstr_f("%s %s", verb, titleEsc.c_str()); } @@ -1055,7 +1017,7 @@ void SciterMainWindow::RefreshFirmwareInstallLoading() if (status.IsValid()) { - const std::string text = stdstr_f("Installing firmware %s", HtmlEscapeForHtmlContent(detail).c_str()); + const std::string text = stdstr_f("Installing firmware %s", HtmlEscape(detail).c_str()); status.SetHTML(reinterpret_cast(text.c_str()), text.size()); } m_sciterUI.UpdateWindow(m_rootElement.GetElementHwnd(true)); diff --git a/src/nxemu/user_interface/settings/profile_editor_dialog.cpp b/src/nxemu/user_interface/settings/profile_editor_dialog.cpp new file mode 100644 index 00000000..609acc33 --- /dev/null +++ b/src/nxemu/user_interface/settings/profile_editor_dialog.cpp @@ -0,0 +1,361 @@ +#include "profile_editor_dialog.h" +#include "profile_image_selector_dialog.h" +#include "user_interface/html_utils.h" +#include "user_interface/notification.h" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +std::string FormatUuid(const HostProfileInfo & profile) +{ + std::string uuid_text; + uuid_text.reserve(36); + static const char * hex = "0123456789ABCDEF"; + for (uint32_t i = 0; i < HOST_PROFILE_UUID_SIZE; ++i) + { + if (i == 4 || i == 6 || i == 8 || i == 10) + { + uuid_text.push_back('-'); + } + uuid_text.push_back(hex[(profile.uuid[i] >> 4) & 0xF]); + uuid_text.push_back(hex[profile.uuid[i] & 0xF]); + } + return uuid_text; +} +} + +ProfileEditorDialog::ProfileEditorDialog(ISciterUI & sciterUI, SystemModules & modules) : + m_sciterUI(sciterUI), + m_modules(modules), + m_window(nullptr), + m_mode(ProfileEditorMode::EditExistingProfile), + m_editingIndex(-1), + m_editingProfile{}, + m_pendingImage{}, + m_changed(false) +{ +} + +ProfileEditorDialog::~ProfileEditorDialog() = default; + +bool ProfileEditorDialog::Display(void * parentWindow, ProfileEditorMode mode, int32_t profileIndex) +{ + enum + { + WINDOW_WIDTH = 520, + }; + + m_mode = mode; + m_editingIndex = profileIndex; + m_editingProfile = {}; + m_pendingImage = {}; + m_changed = false; + m_window = nullptr; + + if (!m_modules.IsValid()) + { + return false; + } + + IOperatingSystem & os = m_modules.Modules().OperatingSystem(); + if (m_mode == ProfileEditorMode::CreateNewProfile) + { + if (os.GetProfileCount() >= HOST_PROFILE_MAX_USERS) + { + Notification::GetInstance().DisplayError("Unable to create profile. A maximum of 8 users is supported.", "Profiles"); + return false; + } + const Common::UUID uuid = Common::UUID::MakeRandom(); + std::memcpy(m_editingProfile.uuid, uuid.uuid.data(), HOST_PROFILE_UUID_SIZE); + m_editingProfile.username[0] = '\0'; + m_editingIndex = -1; + } + else if (m_mode == ProfileEditorMode::EditExistingProfile && !os.GetProfile(static_cast(profileIndex), &m_editingProfile)) + { + return false; + } + + if (!m_sciterUI.WindowCreate(parentWindow, "profile_editor_dialog.html", 0, 0, WINDOW_WIDTH, 0, SUIW_CHILD, m_window)) + { + return false; + } + + SciterElement root(m_window->GetRootElement()); + if (root.IsValid()) + { + SciterElement titleEl(root.GetElementByID("ProfileEditorTitle")); + if (titleEl.IsValid()) + { + titleEl.SetText(m_mode == ProfileEditorMode::CreateNewProfile ? "Create Profile" : "Edit Profile"); + } + + SciterElement usernameInput(root.GetElementByID("ProfileUsername")); + SciterElement userIdInput(root.GetElementByID("ProfileUserId")); + if (usernameInput.IsValid()) + { + usernameInput.SetValue(SciterValue(std::string(m_editingProfile.username))); + } + if (userIdInput.IsValid()) + { + userIdInput.SetValue(SciterValue(FormatUuid(m_editingProfile))); + } + + SetDetailImage(m_mode == ProfileEditorMode::CreateNewProfile ? std::string() : GetImageFileUrl(m_editingProfile)); + UpdateControls(); + + AttachClickHandler(m_sciterUI, root.GetElementByID("profileRemove"), this); + AttachClickHandler(m_sciterUI, root.GetElementByID("profileSetImage"), this); + AttachClickHandler(m_sciterUI, root.GetElementByID("profileSave"), this); + } + + m_window->FixMinSize(); + m_window->CenterWindow(); + m_window->RunModal(); + return m_changed; +} + +bool ProfileEditorDialog::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT /*source*/, uint32_t /*reason*/) +{ + SciterElement clickElem(element); + const std::string elementID = clickElem.GetAttributeByName("id"); + if (!m_modules.IsValid()) + { + return true; + } + + IOperatingSystem & os = m_modules.Modules().OperatingSystem(); + + if (elementID == "profileSave") + { + const std::string username = GetUsernameInput(); + if (username.empty()) + { + Notification::GetInstance().DisplayError("Enter a username for the profile.", "Profiles"); + return true; + } + + if (m_mode == ProfileEditorMode::CreateNewProfile) + { + HostProfileInfo created{}; + if (!os.CreateProfile(m_editingProfile.uuid, username.c_str(), &created)) + { + Notification::GetInstance().DisplayError("Unable to create profile. A maximum of 8 users is supported.", "Profiles"); + return true; + } + + m_editingProfile = created; + m_editingIndex = (int32_t)(os.GetProfileCount() - 1); + + if (m_pendingImage.HasImage() && !CommitPendingImage()) + { + return true; + } + + m_mode = ProfileEditorMode::EditExistingProfile; + m_changed = true; + Close(); + return true; + } + + if (!os.RenameProfile(m_editingProfile.uuid, username.c_str())) + { + Notification::GetInstance().DisplayError("Unable to rename profile.", "Profiles"); + return true; + } + + if (m_pendingImage.HasImage() && !CommitPendingImage()) + { + return true; + } + + m_changed = true; + Close(); + return true; + } + + if (elementID == "profileRemove") + { + if (m_mode == ProfileEditorMode::CreateNewProfile) + { + return true; + } + + const std::string message = stdstr_f("Delete user \"%s\"?\n\nSave data for this user will remain on disk.", m_editingProfile.username); + if (Notification::GetInstance().Query(message.c_str(), "Confirm Delete") != NotificationResponse::Yes) + { + return true; + } + + if (!os.RemoveProfile(m_editingProfile.uuid)) + { + Notification::GetInstance().DisplayError("Unable to delete profile. At least one user must remain.", "Profiles"); + return true; + } + + SettingsStore & settings = SettingsStore::GetInstance(); + if (settings.GetInt(NXOsSetting::CurrentUser) == m_editingIndex) + { + settings.SetInt(NXOsSetting::CurrentUser, 0); + } + else if (settings.GetInt(NXOsSetting::CurrentUser) > m_editingIndex) + { + settings.SetInt(NXOsSetting::CurrentUser, settings.GetInt(NXOsSetting::CurrentUser) - 1); + } + + m_pendingImage = {}; + m_changed = true; + Close(); + return true; + } + + if (elementID == "profileSetImage") + { + ProfileImageSelectorDialog selector(m_sciterUI, m_modules); + PendingProfileImage selection{}; + if (selector.Display((void *)(m_window != nullptr ? m_window->GetHandle() : nullptr), selection)) + { + m_pendingImage = selection; + SetDetailImage(m_pendingImage.HasImage() ? ImageDataUri(m_pendingImage.data.data(), m_pendingImage.data.size()) : std::string{}); + } + return true; + } + + return true; +} + +void ProfileEditorDialog::Close() +{ + if (m_window == nullptr || m_window->IsClosed()) + { + return; + } + m_window->Destroy(); +} + +void ProfileEditorDialog::UpdateControls() +{ + if (m_window == nullptr) + { + return; + } + + SciterElement root(m_window->GetRootElement()); + const uint32_t count = m_modules.IsValid() ? m_modules.Modules().OperatingSystem().GetProfileCount() : 0; + const bool editing = m_mode == ProfileEditorMode::EditExistingProfile; + + SetElementEnabled(root, "profileSave", true); + SetElementEnabled(root, "profileSetImage", true); + SetElementVisible(root, "profileRemove", editing); + SetElementEnabled(root, "profileRemove", editing && count >= 2); +} + +void ProfileEditorDialog::SetDetailImage(const std::string & imageUri) +{ + if (m_window == nullptr) + { + return; + } + + SciterElement image(SciterElement(m_window->GetRootElement()).GetElementByID("ProfileDetailImage")); + if (!image.IsValid()) + { + return; + } + + image.SetAttribute("src", ""); + image.SetStyleAttribute("foreground-image", "none"); + + SciterElement avatar = image.GetParent(); + if (avatar.IsValid()) + { + avatar.SetStyleAttribute("foreground-image", "none"); + avatar.SetStyleAttribute("background", ""); + } + + if (imageUri.empty()) + { + image.SetStyleAttribute("display", "none"); + } + else + { + const std::string foreground = "url(" + imageUri + ")"; + image.SetAttribute("src", imageUri.c_str()); + image.SetStyleAttribute("foreground-image", foreground.c_str()); + image.SetStyleAttribute("foreground-size", "100% 100%"); + image.SetStyleAttribute("background", "transparent"); + image.SetStyleAttribute("display", "block"); + + if (avatar.IsValid()) + { + avatar.SetStyleAttribute("foreground-image", foreground.c_str()); + avatar.SetStyleAttribute("foreground-size", "100% 100%"); + } + } +} + +bool ProfileEditorDialog::CommitPendingImage() +{ + if (!m_modules.IsValid() || !m_pendingImage.HasImage()) + { + return true; + } + + IOperatingSystem & os = m_modules.Modules().OperatingSystem(); + if (!os.SetProfileImage(m_editingProfile.uuid, m_pendingImage.data.data(), (uint32_t)m_pendingImage.data.size())) + { + Notification::GetInstance().DisplayError("Unable to set profile image.", "Profiles"); + return false; + } + + m_pendingImage = {}; + return true; +} + +std::string ProfileEditorDialog::GetUsernameInput() const +{ + if (m_window == nullptr) + { + return {}; + } + + SciterElement input(SciterElement(m_window->GetRootElement()).GetElementByID("ProfileUsername")); + if (!input.IsValid()) + { + return {}; + } + + SciterValue value = input.GetValue(); + if (!value.isString()) + { + return {}; + } + + std::string username = value.GetValueStr(); + if (username.size() > HOST_PROFILE_USERNAME_SIZE) + { + username.resize(HOST_PROFILE_USERNAME_SIZE); + } + return username; +} + +std::string ProfileEditorDialog::GetImageFileUrl(const HostProfileInfo & profile) const +{ + if (!m_modules.IsValid()) + { + return {}; + } + + char path[1024] = {}; + if (!m_modules.Modules().OperatingSystem().GetProfileImagePath(profile.uuid, path, sizeof(path)) || path[0] == '\0') + { + return {}; + } + + return ImageDataUriFromFile(path); +} diff --git a/src/nxemu/user_interface/settings/profile_editor_dialog.h b/src/nxemu/user_interface/settings/profile_editor_dialog.h new file mode 100644 index 00000000..c8d4d454 --- /dev/null +++ b/src/nxemu/user_interface/settings/profile_editor_dialog.h @@ -0,0 +1,49 @@ +#pragma once +#include "profile_image_selector_dialog.h" +#include +#include +#include + +__interface ISciterUI; +__interface ISciterWindow; +class SystemModules; + +enum class ProfileEditorMode +{ + CreateNewProfile, + EditExistingProfile, +}; + +class ProfileEditorDialog : + public IClickSink +{ +public: + ProfileEditorDialog(ISciterUI & sciterUI, SystemModules & modules); + ~ProfileEditorDialog(); + + bool Display(void * parentWindow, ProfileEditorMode mode, int32_t profileIndex = -1); + + // IClickSink + bool OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t reason) override; + +private: + ProfileEditorDialog() = delete; + ProfileEditorDialog(const ProfileEditorDialog &) = delete; + ProfileEditorDialog & operator=(const ProfileEditorDialog &) = delete; + + void Close(); + void UpdateControls(); + void SetDetailImage(const std::string & imageUri); + bool CommitPendingImage(); + std::string GetUsernameInput() const; + std::string GetImageFileUrl(const HostProfileInfo & profile) const; + + ISciterUI & m_sciterUI; + SystemModules & m_modules; + ISciterWindow * m_window; + ProfileEditorMode m_mode; + int32_t m_editingIndex; + HostProfileInfo m_editingProfile; + PendingProfileImage m_pendingImage; + bool m_changed; +}; diff --git a/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.cpp b/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.cpp new file mode 100644 index 00000000..546ab4dc --- /dev/null +++ b/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.cpp @@ -0,0 +1,1039 @@ +#include "profile_firmware_avatar_dialog.h" +#include "user_interface/html_utils.h" +#include "user_interface/notification.h" +#include "yuzu_common/fs/fs.h" +#include "yuzu_common/fs/path_util.h" +#include "yuzu_common/logging/log.h" +#include "yuzu_common/stb.h" +#include "yuzu_common/string_util.h" +#include "yuzu_common/swap.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +enum +{ + TIMER_WAIT_LOAD = 1, + TIMER_LOAD_IMAGES = 2, + IMAGE_BATCH_SIZE = 16, +}; + +std::mutex g_uriCacheMutex; +std::vector g_uriCache; + +const std::string & CachedDataUri(uint32_t index, const std::string & path) +{ + std::lock_guard lock(g_uriCacheMutex); + if (index >= g_uriCache.size()) + { + g_uriCache.resize(index + 1); + } + if (g_uriCache[index].empty()) + { + g_uriCache[index] = ImageDataUriFromFile(path.c_str()); + } + return g_uriCache[index]; +} + +int HexDigit(char c) +{ + if (c >= '0' && c <= '9') + { + return c - '0'; + } + if (c >= 'a' && c <= 'f') + { + return 10 + (c - 'a'); + } + if (c >= 'A' && c <= 'F') + { + return 10 + (c - 'A'); + } + return -1; +} + +constexpr uint64_t AvatarImageTitleId = 0x010000000000080AULL; +constexpr int ProfileDimension = 256; +constexpr int ThumbDimension = 72; +constexpr std::size_t ProfilePixelCount = (std::size_t)ProfileDimension * ProfileDimension; +constexpr std::size_t ProfileRgbaSize = ProfilePixelCount * 4; + +std::filesystem::path AvatarCacheDir() +{ + return Common::FS::GetYuzuPath(Common::FS::YuzuPath::CacheDir) / "firmware_avatars"; +} + +std::string AvatarCacheFileName(uint32_t index, int dim) +{ + return std::to_string(index) + "_t." + std::to_string(dim) + ".png"; +} + +uint32_t ReadBe32(const uint8_t * data) +{ + u32_be value; + std::memcpy(&value, data, sizeof(value)); + return value; +} + +std::vector DecompressYaz0(const std::vector & input) +{ + if (input.size() < 16 || std::memcmp(input.data(), "Yaz0", 4) != 0) + { + return {}; + } + + const uint32_t decoded_length = ReadBe32(input.data() + 4); + if (decoded_length == 0 || decoded_length > (16 * 1024 * 1024)) + { + return {}; + } + + std::size_t input_offset = 16; + std::vector output(decoded_length); + std::size_t output_offset = 0; + + uint16_t mask = 0; + uint8_t header = 0; + + while (output_offset < decoded_length) + { + mask >>= 1; + if (mask == 0) + { + if (input_offset >= input.size()) + { + break; + } + header = input[input_offset++]; + mask = 0x80; + } + + if ((header & mask) != 0) + { + if (input_offset >= input.size() || output_offset >= output.size()) + { + break; + } + output[output_offset++] = input[input_offset++]; + } + else + { + if (input_offset + 1 >= input.size()) + { + break; + } + + const uint8_t byte1 = input[input_offset++]; + const uint8_t byte2 = input[input_offset++]; + const uint32_t dist = ((uint32_t)(byte1 & 0xF) << 8) | byte2; + std::size_t position = output_offset - (dist + 1); + + uint32_t length = (uint32_t)byte1 >> 4; + if (length == 0) + { + if (input_offset >= input.size()) + { + break; + } + length = (uint32_t)input[input_offset++] + 0x12; + } + else + { + length += 2; + } + + if (position >= output.size()) + { + break; + } + + for (uint32_t i = 0; i < length && output_offset < decoded_length; ++i) + { + output[output_offset++] = output[position++]; + } + } + } + + if (output_offset != decoded_length) + { + return {}; + } + return output; +} + +bool PathLooksLikeAvatarSzs(const std::string & path) +{ + // Firmware stores avatars as chara/00000001.szs (directory name + numbered szs). + // Match the full path so "chara" in a parent directory is recognized. + const std::string lower = Common::ToLower(path); + return lower.find("chara") != std::string::npos && lower.find("szs") != std::string::npos; +} + +std::vector UnpremultiplyToStraightRgba(const std::vector & premul) +{ + std::vector straight = premul; + for (std::size_t i = 0; i + 3 < straight.size(); i += 4) + { + const uint8_t a = straight[i + 3]; + if (a == 0) + { + straight[i] = 0; + straight[i + 1] = 0; + straight[i + 2] = 0; + continue; + } + if (a == 255) + { + continue; + } + straight[i] = (uint8_t)(std::min(255, (straight[i] * 255) / a)); + straight[i + 1] = (uint8_t)(std::min(255, (straight[i + 1] * 255) / a)); + straight[i + 2] = (uint8_t)(std::min(255, (straight[i + 2] * 255) / a)); + } + return straight; +} + +std::vector DownsamplePremulRgba(const std::vector & premul_rgba, int src_dim, int dst_dim) +{ + std::vector out((std::size_t)(dst_dim) * (std::size_t)(dst_dim) * 4); + for (int y = 0; y < dst_dim; ++y) + { + const int sy = y * src_dim / dst_dim; + for (int x = 0; x < dst_dim; ++x) + { + const int sx = x * src_dim / dst_dim; + const uint8_t * src = &premul_rgba[((std::size_t)(sy) * src_dim + sx) * 4]; + uint8_t * dst = &out[((std::size_t)(y) * dst_dim + x) * 4]; + dst[0] = src[0]; + dst[1] = src[1]; + dst[2] = src[2]; + dst[3] = src[3]; + } + } + return out; +} + +bool WriteStraightRgbaPng(const std::vector & straight_rgba, int dim, const std::filesystem::path & path) +{ + if (!Common::FS::CreateParentDirs(path)) + { + return false; + } + + const std::string path_utf8 = Common::FS::PathToUTF8String(path); + return stbi_write_png(path_utf8.c_str(), dim, dim, 4, straight_rgba.data(), dim * 4) != 0; +} + +std::vector LoadStraightRgba256(const std::string & path_utf8) +{ + std::ifstream file(path_utf8.c_str(), std::ios::binary); + if (!file) + { + return {}; + } + + const std::vector encoded((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + if (encoded.empty()) + { + return {}; + } + + int width = 0; + int height = 0; + int channels = 0; + unsigned char * pixels = stbi_load_from_memory(encoded.data(), (int)(encoded.size()), &width, &height, &channels, STBI_rgb_alpha); + if (pixels == nullptr) + { + return {}; + } + + std::vector rgba; + if (width == ProfileDimension && height == ProfileDimension) + { + rgba.assign(pixels, pixels + ProfileRgbaSize); + } + stbi_image_free(pixels); + return rgba; +} + +bool CacheFileReady(const std::filesystem::path & path) +{ + std::error_code ec; + return std::filesystem::exists(path, ec) && !ec && std::filesystem::file_size(path, ec) > 0 && !ec; +} + +void CollectAvatarFiles(IVirtualDirectory * directory, const std::string & path_prefix, + std::vector>> & out_files) +{ + if (directory == nullptr) + { + return; + } + + { + IVirtualFileListPtr files(directory->GetFiles()); + if (files) + { + const uint32_t count = files->GetSize(); + for (uint32_t i = 0; i < count; ++i) + { + IVirtualFilePtr file(files->GetItem(i)); + if (!file) + { + continue; + } + + const char * name_cstr = file->GetName(); + if (name_cstr == nullptr) + { + continue; + } + + const std::string name(name_cstr); + const std::string full_path = path_prefix + name; + if (!PathLooksLikeAvatarSzs(full_path)) + { + continue; + } + + std::vector data = file.ReadAllBytes(); + if (!data.empty()) + { + out_files.emplace_back(full_path, std::move(data)); + } + } + } + } + + { + IVirtualDirectoryListPtr subdirs(directory->GetSubdirectories()); + if (!subdirs) + { + return; + } + + const uint32_t count = subdirs->GetSize(); + for (uint32_t i = 0; i < count; ++i) + { + IVirtualDirectoryPtr subdir(subdirs->GetItem(i)); + if (!subdir) + { + continue; + } + const char * name_cstr = subdir->GetName(); + const std::string name = name_cstr != nullptr ? name_cstr : ""; + CollectAvatarFiles(subdir.Get(), path_prefix + name + "/", out_files); + } + } +} + +std::vector EnumerateCachedAvatars() +{ + std::vector avatars; + const std::filesystem::path cache_dir = AvatarCacheDir(); + for (uint32_t index = 0;; ++index) + { + const std::filesystem::path full = cache_dir / AvatarCacheFileName(index, ProfileDimension); + const std::filesystem::path thumb = cache_dir / AvatarCacheFileName(index, ThumbDimension); + if (!CacheFileReady(full) || !CacheFileReady(thumb)) + { + break; + } + + FirmwareProfileAvatar avatar; + avatar.full_path = Common::FS::PathToUTF8String(full); + avatar.thumb_path = Common::FS::PathToUTF8String(thumb); + avatars.push_back(std::move(avatar)); + } + return avatars; +} + +void BuildAvatarCacheFromFirmware(ISystemModules & modules, FirmwareAvatarLoadState & state) +{ + ISystemloader & loader = modules.Systemloader(); + FileSysNCAPtr nca(loader.GetContentProviderEntry(AvatarImageTitleId, LoaderContentRecordType::Data)); + if (!nca || nca->GetStatus() != LoaderResultStatus::Success) + { + LOG_WARNING(Service_ACC, "AvatarImage NCA 010000000000080A not found or failed to open"); + state.done.store(true); + return; + } + + IVirtualFilePtr romfs_file(nca->GetRomFS()); + if (!romfs_file) + { + LOG_WARNING(Service_ACC, "AvatarImage NCA has no RomFS section"); + state.done.store(true); + return; + } + + IVirtualDirectoryPtr romfs(romfs_file->ExtractRomFS()); + if (!romfs) + { + LOG_WARNING(Service_ACC, "AvatarImage RomFS extract failed"); + state.done.store(true); + return; + } + + std::vector>> compressed_files; + CollectAvatarFiles(romfs.Get(), "", compressed_files); + if (compressed_files.empty()) + { + LOG_WARNING(Service_ACC, "AvatarImage RomFS contained no chara*.szs avatars"); + state.done.store(true); + return; + } + + std::sort(compressed_files.begin(), compressed_files.end(), + [](const auto & a, const auto & b) { return a.first < b.first; }); + + const std::filesystem::path cache_dir = AvatarCacheDir(); + void(Common::FS::CreateDir(cache_dir)); + + uint32_t index = 0; + for (auto & [name, compressed] : compressed_files) + { + const std::vector rgba = DecompressYaz0(compressed); + if (rgba.size() != ProfileRgbaSize) + { + LOG_WARNING(Service_ACC, "Skipping firmware avatar '{}' (unexpected size {})", name, rgba.size()); + continue; + } + + const std::vector straight = UnpremultiplyToStraightRgba(rgba); + const std::vector thumb = DownsamplePremulRgba(straight, ProfileDimension, ThumbDimension); + + const std::filesystem::path full_path = cache_dir / AvatarCacheFileName(index, ProfileDimension); + const std::filesystem::path thumb_path = cache_dir / AvatarCacheFileName(index, ThumbDimension); + if (!WriteStraightRgbaPng(straight, ProfileDimension, full_path) || + !WriteStraightRgbaPng(thumb, ThumbDimension, thumb_path)) + { + LOG_WARNING(Service_ACC, "Failed to write firmware avatar cache for '{}'", name); + continue; + } + + FirmwareProfileAvatar avatar; + avatar.full_path = Common::FS::PathToUTF8String(full_path); + avatar.thumb_path = Common::FS::PathToUTF8String(thumb_path); + { + std::lock_guard lock(state.mutex); + state.avatars.push_back(std::move(avatar)); + } + ++index; + } + + state.done.store(true); +} + +void PngToMemory(void * context, void * data, int len) +{ + auto * png = (std::vector *)context; + const auto * bytes = (unsigned char *)data; + png->insert(png->end(), bytes, bytes + len); +} + +std::vector BuildFirmwareAvatarProfileImage(const std::vector & straight, uint8_t background_r, uint8_t background_g, uint8_t background_b) +{ + if (straight.size() != ProfileRgbaSize) + { + return {}; + } + + std::vector rgb(ProfilePixelCount * 3); + for (std::size_t i = 0; i < ProfilePixelCount; ++i) + { + rgb[i * 3 + 0] = background_r; + rgb[i * 3 + 1] = background_g; + rgb[i * 3 + 2] = background_b; + } + + for (std::size_t i = 0; i < ProfilePixelCount; ++i) + { + const uint8_t * px = &straight[i * 4]; + const uint32_t a = px[3]; + if (a == 0) + { + continue; + } + if (a == 255) + { + rgb[i * 3 + 0] = px[0]; + rgb[i * 3 + 1] = px[1]; + rgb[i * 3 + 2] = px[2]; + continue; + } + + const uint32_t inv_a = 255 - a; + rgb[i * 3 + 0] = (uint8_t)((px[0] * a + background_r * inv_a) / 255); + rgb[i * 3 + 1] = (uint8_t)((px[1] * a + background_g * inv_a) / 255); + rgb[i * 3 + 2] = (uint8_t)((px[2] * a + background_b * inv_a) / 255); + } + + std::vector png; + if (stbi_write_png_to_func(PngToMemory, &png, ProfileDimension, ProfileDimension, 3, rgb.data(), ProfileDimension * 3) == 0) + { + return {}; + } + return png; +} +} + +ProfileFirmwareAvatarDialog::ProfileFirmwareAvatarDialog(ISciterUI & sciterUI, SystemModules & modules) : + m_sciterUI(sciterUI), + m_modules(modules), + m_window(nullptr), + m_selectedIndex(-1), + m_backgroundColor("#FFFFFF"), + m_selection{}, + m_changed(false), + m_nextImageIndex(0) +{ +} + +ProfileFirmwareAvatarDialog::~ProfileFirmwareAvatarDialog() = default; + +bool ProfileFirmwareAvatarDialog::Display(void * parentWindow, PendingProfileImage & outSelection) +{ + enum + { + WINDOW_WIDTH = 560, + }; + + m_selectedIndex = -1; + m_backgroundColor = "#FFFFFF"; + m_selection = {}; + m_changed = false; + m_window = nullptr; + m_avatars.clear(); + m_pngPaths.clear(); + m_nextImageIndex = 0; + m_loadState.reset(); + outSelection = {}; + + if (!m_sciterUI.WindowCreate(parentWindow, "profile_firmware_avatar_dialog.html", 0, 0, WINDOW_WIDTH, 0, SUIW_CHILD, m_window)) + { + return false; + } + SciterElement root(m_window->GetRootElement()); + if (root.IsValid()) + { + SciterElement empty(root.GetElementByID("FirmwareAvatarEmpty")); + SciterElement grid(root.GetElementByID("FirmwareAvatarGrid")); + if (empty.IsValid()) + { + empty.SetText("Loading firmware avatars..."); + empty.SetStyleAttribute("display", "block"); + } + if (grid.IsValid()) + { + grid.SetStyleAttribute("display", "none"); + } + + AttachClickHandler(m_sciterUI, root.GetElementByID("firmwareAvatarChoose"), this); + AttachClickHandler(m_sciterUI, root.GetElementByID("FirmwareAvatarColors"), this); + AttachClickHandler(m_sciterUI, root.GetElementByID("FirmwareAvatarGrid"), this); + + m_sciterUI.AttachHandler(root, IID_ITIMERSINK, (ITimerSink *)this); + + m_avatars = EnumerateCachedAvatars(); + if (!m_avatars.empty()) + { + PopulateGridStructure(); + if (!m_pngPaths.empty()) + { + root.SetTimer(1, (uint32_t *)TIMER_LOAD_IMAGES); + } + } + else + { + StartAvatarLoad(); + root.SetTimer(16, (uint32_t *)TIMER_WAIT_LOAD); + } + } + + m_window->FixMinSize(); + m_window->CenterWindow(); + m_window->RunModal(); + + if (m_changed) + { + outSelection = m_selection; + } + return m_changed; +} + +void ProfileFirmwareAvatarDialog::StartAvatarLoad() +{ + m_loadState = std::make_shared(); + std::shared_ptr state = m_loadState; + SystemModules * modules = &m_modules; + std::thread([state, modules]() { + if (modules != nullptr && modules->IsValid()) + { + BuildAvatarCacheFromFirmware(modules->Modules(), *state); + } + else + { + state->done.store(true); + } + }).detach(); +} + +bool ProfileFirmwareAvatarDialog::OnTimer(SCITER_ELEMENT /*element*/, uint32_t * timerId) +{ + if (timerId == (uint32_t *)TIMER_WAIT_LOAD) + { + if (m_loadState == nullptr) + { + return false; + } + + SyncAvatarsFromLoad(); + if (m_nextImageIndex < m_pngPaths.size()) + { + ApplyNextImageBatch(); + } + + if (m_loadState->done.load()) + { + SyncAvatarsFromLoad(); + if (m_avatars.empty()) + { + ShowEmptyAvatarsMessage(); + } + m_loadState.reset(); + if (m_window != nullptr && m_nextImageIndex < m_pngPaths.size()) + { + SciterElement(m_window->GetRootElement()).SetTimer(1, (uint32_t *)TIMER_LOAD_IMAGES); + } + return false; + } + + return true; + } + + if (timerId == (uint32_t *)TIMER_LOAD_IMAGES) + { + ApplyNextImageBatch(); + return m_nextImageIndex < m_pngPaths.size(); + } + + return false; +} + +bool ProfileFirmwareAvatarDialog::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t /*reason*/) +{ + SciterElement clickElem(element); + SciterElement sourceElem(source); + const std::string elementID = clickElem.GetAttributeByName("id"); + + if (elementID == "firmwareAvatarChoose") + { + if (ChooseSelected()) + { + m_changed = true; + Close(); + } + return true; + } + + SciterElement walk = sourceElem.IsValid() ? sourceElem : clickElem; + while (walk.IsValid()) + { + const std::string walkRole = walk.GetAttribute("role"); + if (walkRole == "firmware-color") + { + const std::string color = walk.GetAttribute("data-color"); + if (!color.empty()) + { + SelectBackgroundColor(color); + } + return true; + } + + if (walkRole == "firmware-avatar") + { + const std::string indexAttr = walk.GetAttribute("data-index"); + if (!indexAttr.empty()) + { + const int32_t newIndex = std::stoi(indexAttr); + if (newIndex != m_selectedIndex) + { + m_selectedIndex = newIndex; + UpdateSelectionHighlight(); + } + } + return true; + } + + if (walk.GetAttributeByName("id") == "FirmwareAvatarGrid" || + walk.GetAttributeByName("id") == "FirmwareAvatarColors") + { + break; + } + walk = walk.GetParent(); + } + + return true; +} + +void ProfileFirmwareAvatarDialog::Close() +{ + if (m_window == nullptr || m_window->IsClosed()) + { + return; + } + m_window->Destroy(); +} + +void ProfileFirmwareAvatarDialog::PopulateGridStructure() +{ + if (m_window == nullptr || !m_modules.IsValid()) + { + return; + } + + SciterElement root(m_window->GetRootElement()); + SciterElement grid(root.GetElementByID("FirmwareAvatarGrid")); + SciterElement empty(root.GetElementByID("FirmwareAvatarEmpty")); + if (!grid.IsValid()) + { + return; + } + + const uint32_t count = (uint32_t)(m_avatars.size()); + + if (empty.IsValid()) + { + if (count == 0) + { + ShowEmptyAvatarsMessage(); + } + else + { + empty.SetStyleAttribute("display", "none"); + } + } + grid.SetStyleAttribute("display", count == 0 ? "none" : "block"); + + m_pngPaths.clear(); + m_nextImageIndex = 0; + + const std::vector & avatars = m_avatars; + { + std::lock_guard lock(g_uriCacheMutex); + if (g_uriCache.size() != count) + { + g_uriCache.clear(); + } + } + + std::string html; + m_pngPaths.reserve(count); + for (uint32_t i = 0; i < count && i < avatars.size(); ++i) + { + if (avatars[i].thumb_path.empty()) + { + continue; + } + + m_pngPaths.emplace_back(avatars[i].thumb_path); + html += stdstr_f( + "
" + "" + "
", + static_cast(i) == m_selectedIndex ? " selected" : "", + i); + } + + grid.SetHTML(reinterpret_cast(html.c_str()), html.size()); + m_sciterUI.AttachHandler(grid, IID_ICLICKSINK, (IClickSink *)this); + ApplyBackgroundColorPreview(); +} + +void ProfileFirmwareAvatarDialog::SyncAvatarsFromLoad() +{ + if (m_loadState == nullptr || m_window == nullptr) + { + return; + } + + std::vector pending; + { + std::lock_guard lock(m_loadState->mutex); + if (m_loadState->avatars.size() <= m_avatars.size()) + { + return; + } + pending.assign(m_loadState->avatars.begin() + m_avatars.size(), m_loadState->avatars.end()); + } + + SciterElement root(m_window->GetRootElement()); + SciterElement grid(root.GetElementByID("FirmwareAvatarGrid")); + SciterElement empty(root.GetElementByID("FirmwareAvatarEmpty")); + if (!grid.IsValid()) + { + return; + } + + const uint32_t start_index = (uint32_t)(m_avatars.size()); + m_avatars.insert(m_avatars.end(), pending.begin(), pending.end()); + + if (empty.IsValid()) + { + empty.SetStyleAttribute("display", "none"); + } + grid.SetStyleAttribute("display", "block"); + + std::string html; + html.reserve(pending.size() * 128); + for (uint32_t i = start_index; i < (uint32_t)(m_avatars.size()); ++i) + { + if (m_avatars[i].thumb_path.empty()) + { + continue; + } + + m_pngPaths.emplace_back(m_avatars[i].thumb_path); + html += stdstr_f( + "
" + "" + "
", + (int32_t)(i) == m_selectedIndex ? " selected" : "", + i); + } + + if (!html.empty()) + { + grid.SetHTML(reinterpret_cast(html.c_str()), html.size(), + SciterElement::SIH_APPEND_AFTER_LAST); + m_sciterUI.AttachHandler(grid, IID_ICLICKSINK, (IClickSink *)this); + ApplyBackgroundColorPreview(); + } +} + +void ProfileFirmwareAvatarDialog::ShowEmptyAvatarsMessage() +{ + if (m_window == nullptr) + { + return; + } + + SciterElement root(m_window->GetRootElement()); + SciterElement grid(root.GetElementByID("FirmwareAvatarGrid")); + SciterElement empty(root.GetElementByID("FirmwareAvatarEmpty")); + if (empty.IsValid()) + { + empty.SetText("No firmware avatars were found. Ensure a complete system firmware install that includes AvatarImage (010000000000080A)."); + empty.SetStyleAttribute("display", "block"); + } + if (grid.IsValid()) + { + grid.SetStyleAttribute("display", "none"); + } +} + +void ProfileFirmwareAvatarDialog::ApplyNextImageBatch() +{ + if (m_window == nullptr) + { + m_nextImageIndex = static_cast(m_pngPaths.size()); + return; + } + + SciterElement grid(SciterElement(m_window->GetRootElement()).GetElementByID("FirmwareAvatarGrid")); + if (!grid.IsValid()) + { + m_nextImageIndex = static_cast(m_pngPaths.size()); + return; + } + + const uint32_t tileCount = (std::min)(grid.GetChildCount(), static_cast(m_pngPaths.size())); + const uint32_t end = (std::min)(m_nextImageIndex + IMAGE_BATCH_SIZE, tileCount); + for (; m_nextImageIndex < end; ++m_nextImageIndex) + { + SciterElement tile = grid.GetChild(m_nextImageIndex); + if (!tile.IsValid()) + { + continue; + } + + SciterElement img = tile.FindFirst("img"); + if (!img.IsValid()) + { + continue; + } + + const std::string & dataUri = CachedDataUri(m_nextImageIndex, m_pngPaths[m_nextImageIndex]); + if (!dataUri.empty()) + { + img.SetAttribute("src", dataUri.c_str()); + } + } +} + +void ProfileFirmwareAvatarDialog::UpdateSelectionHighlight() +{ + if (m_window == nullptr) + { + return; + } + + SciterElement grid(SciterElement(m_window->GetRootElement()).GetElementByID("FirmwareAvatarGrid")); + if (!grid.IsValid()) + { + return; + } + + for (uint32_t i = 0; i < grid.GetChildCount(); ++i) + { + SciterElement tile = grid.GetChild(i); + if (!tile.IsValid()) + { + continue; + } + + const std::string indexAttr = tile.GetAttribute("data-index"); + if (indexAttr.empty()) + { + continue; + } + + if (std::stoi(indexAttr) == m_selectedIndex) + { + tile.AddClassName("selected"); + } + else + { + tile.RemoveClassName("selected"); + } + } +} + +void ProfileFirmwareAvatarDialog::ApplyBackgroundColorPreview() +{ + if (m_window == nullptr) + { + return; + } + + SciterElement grid(SciterElement(m_window->GetRootElement()).GetElementByID("FirmwareAvatarGrid")); + if (!grid.IsValid()) + { + return; + } + + for (uint32_t i = 0; i < grid.GetChildCount(); ++i) + { + SciterElement tile = grid.GetChild(i); + if (tile.IsValid()) + { + tile.SetStyleAttribute("background-color", m_backgroundColor.c_str()); + } + } +} + +void ProfileFirmwareAvatarDialog::SelectBackgroundColor(const std::string & colorCss) +{ + m_backgroundColor = colorCss; + ApplyBackgroundColorPreview(); + + if (m_window == nullptr) + { + return; + } + + SciterElement colors(SciterElement(m_window->GetRootElement()).GetElementByID("FirmwareAvatarColors")); + if (!colors.IsValid()) + { + return; + } + + for (uint32_t i = 0; i < colors.GetChildCount(); ++i) + { + SciterElement swatch = colors.GetChild(i); + if (!swatch.IsValid()) + { + continue; + } + + if (swatch.GetAttribute("data-color") == m_backgroundColor) + { + swatch.AddClassName("selected"); + } + else + { + swatch.RemoveClassName("selected"); + } + } +} + +bool ProfileFirmwareAvatarDialog::ParseSelectedColor(uint8_t & r, uint8_t & g, uint8_t & b) const +{ + r = 255; + g = 255; + b = 255; + + if (m_backgroundColor.size() != 7 || m_backgroundColor[0] != '#') + { + return false; + } + + const int rh = HexDigit(m_backgroundColor[1]); + const int rl = HexDigit(m_backgroundColor[2]); + const int gh = HexDigit(m_backgroundColor[3]); + const int gl = HexDigit(m_backgroundColor[4]); + const int bh = HexDigit(m_backgroundColor[5]); + const int bl = HexDigit(m_backgroundColor[6]); + if (rh < 0 || rl < 0 || gh < 0 || gl < 0 || bh < 0 || bl < 0) + { + return false; + } + + r = static_cast((rh << 4) | rl); + g = static_cast((gh << 4) | gl); + b = static_cast((bh << 4) | bl); + return true; +} + +bool ProfileFirmwareAvatarDialog::ChooseSelected() +{ + if (m_selectedIndex < 0) + { + Notification::GetInstance().DisplayError("Select a firmware avatar first.", "Profiles"); + return false; + } + + uint8_t r = 255; + uint8_t g = 255; + uint8_t b = 255; + ParseSelectedColor(r, g, b); + + const std::vector & avatars = m_avatars; + if (static_cast(m_selectedIndex) >= avatars.size()) + { + Notification::GetInstance().DisplayError("Select a firmware avatar first.", "Profiles"); + return false; + } + + const std::vector straight = LoadStraightRgba256(avatars[m_selectedIndex].full_path); + std::vector data = BuildFirmwareAvatarProfileImage(straight, r, g, b); + if (data.empty()) + { + Notification::GetInstance().DisplayError("Unable to build the selected avatar image.", "Profiles"); + return false; + } + + m_selection = {}; + m_selection.data = std::move(data); + return true; +} diff --git a/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.h b/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.h new file mode 100644 index 00000000..5cf76d4b --- /dev/null +++ b/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.h @@ -0,0 +1,72 @@ +#pragma once +#include "profile_image_selector_dialog.h" +#include +#include +#include +#include +#include +#include + +__interface ISciterUI; +class SystemModules; + +struct FirmwareProfileAvatar +{ + std::string thumb_path; + std::string full_path; +}; + +struct FirmwareAvatarLoadState +{ + std::atomic_bool done{false}; + std::mutex mutex; + std::vector avatars; +}; + +class ProfileFirmwareAvatarDialog : + public IClickSink, + public ITimerSink +{ +public: + ProfileFirmwareAvatarDialog(ISciterUI & sciterUI, SystemModules & modules); + ~ProfileFirmwareAvatarDialog(); + + bool Display(void * parentWindow, PendingProfileImage & outSelection); + + // IClickSink + bool OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t reason) override; + + // ITimerSink + bool OnTimer(SCITER_ELEMENT element, uint32_t * timerId) override; + +private: + ProfileFirmwareAvatarDialog() = delete; + ProfileFirmwareAvatarDialog(const ProfileFirmwareAvatarDialog &) = delete; + ProfileFirmwareAvatarDialog & operator=(const ProfileFirmwareAvatarDialog &) = delete; + + void Close(); + void StartAvatarLoad(); + void PopulateGridStructure(); + void SyncAvatarsFromLoad(); + void ShowEmptyAvatarsMessage(); + void ApplyNextImageBatch(); + void UpdateSelectionHighlight(); + void ApplyBackgroundColorPreview(); + void SelectBackgroundColor(const std::string & colorCss); + bool ParseSelectedColor(uint8_t & r, uint8_t & g, uint8_t & b) const; + bool ChooseSelected(); + + ISciterUI & m_sciterUI; + SystemModules & m_modules; + ISciterWindow * m_window; + int32_t m_selectedIndex; + std::string m_backgroundColor; + PendingProfileImage m_selection; + bool m_changed; + + std::vector m_avatars; + std::vector m_pngPaths; + uint32_t m_nextImageIndex; + + std::shared_ptr m_loadState; +}; diff --git a/src/nxemu/user_interface/settings/profile_image_selector_dialog.cpp b/src/nxemu/user_interface/settings/profile_image_selector_dialog.cpp new file mode 100644 index 00000000..890d78ed --- /dev/null +++ b/src/nxemu/user_interface/settings/profile_image_selector_dialog.cpp @@ -0,0 +1,156 @@ +#include "profile_image_selector_dialog.h" +#include "profile_firmware_avatar_dialog.h" +#include "user_interface/html_utils.h" +#include +#include +#include +#include +#include +#include + +ProfileImageSelectorDialog::ProfileImageSelectorDialog(ISciterUI & sciterUI, SystemModules & modules) : + m_sciterUI(sciterUI), + m_modules(modules), + m_window(nullptr), + m_selection{}, + m_changed(false) +{ +} + +ProfileImageSelectorDialog::~ProfileImageSelectorDialog() = default; + +bool ProfileImageSelectorDialog::Display(void * parentWindow, PendingProfileImage & outSelection) +{ + enum + { + WINDOW_WIDTH = 500, + }; + + m_selection = {}; + m_changed = false; + m_window = nullptr; + outSelection = {}; + + if (!m_sciterUI.WindowCreate(parentWindow, "profile_image_selector_dialog.html", 0, 0, WINDOW_WIDTH, 0, SUIW_CHILD, m_window)) + { + return false; + } + + SciterElement root(m_window->GetRootElement()); + if (root.IsValid()) + { + SciterElement firmwareButton(root.GetElementByID("profileImageFirmware")); + if (firmwareButton.IsValid()) + { + if (HasInstalledFirmware()) + { + firmwareButton.SetState(0, SciterElement::STATE_DISABLED, true); + } + else + { + firmwareButton.SetState(SciterElement::STATE_DISABLED, 0, true); + } + } + + AttachClickHandler(m_sciterUI, root.GetElementByID("profileImageImport"), this); + AttachClickHandler(m_sciterUI, root.GetElementByID("profileImageFirmware"), this); + } + + m_window->FixMinSize(); + m_window->CenterWindow(); + m_window->RunModal(); + + if (m_changed) + { + outSelection = m_selection; + } + return m_changed; +} + +bool ProfileImageSelectorDialog::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT /*source*/, uint32_t /*reason*/) +{ + SciterElement clickElem(element); + const std::string elementID = clickElem.GetAttributeByName("id"); + + if (elementID == "profileImageImport") + { + if (ImportImageFile()) + { + m_changed = true; + Close(); + } + return true; + } + + if (elementID == "profileImageFirmware") + { + if (SelectFirmwareAvatar()) + { + m_changed = true; + Close(); + } + return true; + } + + return true; +} + +void ProfileImageSelectorDialog::Close() +{ + if (m_window == nullptr || m_window->IsClosed()) + { + return; + } + m_window->Destroy(); +} + +bool ProfileImageSelectorDialog::HasInstalledFirmware() const +{ + if (!m_modules.IsValid()) + { + return false; + } + + char buffer[32]{}; + return m_modules.Modules().Systemloader().GetInstalledFirmwareDisplayVersion(buffer, sizeof(buffer)) > 0; +} + +bool ProfileImageSelectorDialog::ImportImageFile() +{ + Path file; + const char * filter = + "Image Files (*.jpg;*.jpeg;*.png;*.bmp)\0*.jpg;*.jpeg;*.png;*.bmp\0" + "All files (*.*)\0*.*\0"; + if (!file.FileSelect((void *)(m_window != nullptr ? m_window->GetHandle() : nullptr), Path(Path::CURRENT_DIRECTORY), filter, true)) + { + return false; + } + + std::ifstream stream(static_cast(file), std::ios::binary); + if (!stream) + { + return false; + } + + m_selection = {}; + m_selection.data.assign(std::istreambuf_iterator(stream), std::istreambuf_iterator()); + return m_selection.HasImage(); +} + +bool ProfileImageSelectorDialog::SelectFirmwareAvatar() +{ + if (!HasInstalledFirmware()) + { + return false; + } + + ProfileFirmwareAvatarDialog dialog(m_sciterUI, m_modules); + PendingProfileImage selection{}; + if (!dialog.Display((void *)(m_window != nullptr ? m_window->GetHandle() : nullptr), selection)) + { + return false; + } + + m_selection = selection; + return true; +} diff --git a/src/nxemu/user_interface/settings/profile_image_selector_dialog.h b/src/nxemu/user_interface/settings/profile_image_selector_dialog.h new file mode 100644 index 00000000..c33fd217 --- /dev/null +++ b/src/nxemu/user_interface/settings/profile_image_selector_dialog.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include + +__interface ISciterUI; +__interface ISciterWindow; +class SystemModules; + +struct PendingProfileImage +{ + std::vector data; + + bool HasImage() const { return !data.empty(); } +}; + +class ProfileImageSelectorDialog : + public IClickSink +{ +public: + ProfileImageSelectorDialog(ISciterUI & sciterUI, SystemModules & modules); + ~ProfileImageSelectorDialog(); + + bool Display(void * parentWindow, PendingProfileImage & outSelection); + + // IClickSink + bool OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t reason) override; + +private: + ProfileImageSelectorDialog() = delete; + ProfileImageSelectorDialog(const ProfileImageSelectorDialog &) = delete; + ProfileImageSelectorDialog & operator=(const ProfileImageSelectorDialog &) = delete; + + void Close(); + bool ImportImageFile(); + bool SelectFirmwareAvatar(); + bool HasInstalledFirmware() const; + + ISciterUI & m_sciterUI; + SystemModules & m_modules; + ISciterWindow * m_window; + PendingProfileImage m_selection; + bool m_changed; +}; diff --git a/src/nxemu/user_interface/settings/system_config.cpp b/src/nxemu/user_interface/settings/system_config.cpp index e2d0015f..e11096b3 100644 --- a/src/nxemu/user_interface/settings/system_config.cpp +++ b/src/nxemu/user_interface/settings/system_config.cpp @@ -4,6 +4,7 @@ #include "system_config_audio.h" #include "system_config_general.h" #include "system_config_graphics.h" +#include "system_config_profiles.h" #include "system_config_system.h" #include #include @@ -330,6 +331,10 @@ void SystemConfig::PageNavCreatedPage(const std::string & pageName, SCITER_ELEME { m_systemConfigGeneral.reset(new SystemConfigGeneral(m_sciterUI, *this, *m_window, page)); } + else if (pageName == "Profiles") + { + m_systemConfigProfiles.reset(new SystemConfigProfiles(m_sciterUI, *this, m_modules, *m_window, page)); + } } void SystemConfig::PageNavPageChanged(const std::string & /*pageName*/, SCITER_ELEMENT /*pageNav*/) @@ -353,6 +358,10 @@ bool SystemConfig::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT /*source*/, ui { m_systemConfigGeneral->SaveSetting(); } + if (m_systemConfigProfiles) + { + m_systemConfigProfiles->SaveSetting(); + } if (m_modules.IsValid()) { m_modules.FlushSettings(); diff --git a/src/nxemu/user_interface/settings/system_config.h b/src/nxemu/user_interface/settings/system_config.h index adf39529..cce81d10 100644 --- a/src/nxemu/user_interface/settings/system_config.h +++ b/src/nxemu/user_interface/settings/system_config.h @@ -13,6 +13,7 @@ class SciterElement; class SystemConfigAudio; class SystemConfigGraphics; class SystemConfigGeneral; +class SystemConfigProfiles; class SystemConfigSystem; class ConfigSetting; class SystemModules; @@ -86,4 +87,5 @@ private: std::unique_ptr m_systemConfigGraphics; std::unique_ptr m_systemConfigAudio; std::unique_ptr m_systemConfigGeneral; + std::unique_ptr m_systemConfigProfiles; }; diff --git a/src/nxemu/user_interface/settings/system_config_profiles.cpp b/src/nxemu/user_interface/settings/system_config_profiles.cpp new file mode 100644 index 00000000..94beb26f --- /dev/null +++ b/src/nxemu/user_interface/settings/system_config_profiles.cpp @@ -0,0 +1,291 @@ +#include "system_config_profiles.h" +#include "profile_editor_dialog.h" +#include "user_interface/html_utils.h" +#include "user_interface/notification.h" +#include +#include +#include +#include +#include + +SystemConfigProfiles::SystemConfigProfiles(ISciterUI & sciterUI, SystemConfig & config, SystemModules & modules, ISciterWindow & window, SciterElement page) : + m_sciterUI(sciterUI), + m_config(config), + m_modules(modules), + m_window(window), + m_page(page), + m_enabled(!SettingsStore::GetInstance().GetBool(NXCoreSetting::EmulationRunning)), + m_currentUser(SettingsStore::GetInstance().GetInt(NXOsSetting::CurrentUser)) +{ + SciterElement pageNav = page.GetElementByID("ProfilesTabNav"); + std::shared_ptr interfacePtr = pageNav.IsValid() ? m_sciterUI.GetElementInterface(pageNav, IID_IPAGENAV) : nullptr; + if (interfacePtr) + { + m_pageNav = std::static_pointer_cast(interfacePtr); + m_pageNav->AddSink(this); + } +} + +void SystemConfigProfiles::SaveSetting(void) +{ + SettingsStore::GetInstance().SetInt(NXOsSetting::CurrentUser, m_currentUser); +} + +bool SystemConfigProfiles::PageNavChangeFrom(const std::string & /*pageName*/, SCITER_ELEMENT /*pageNav*/) +{ + return true; +} + +bool SystemConfigProfiles::PageNavChangeTo(const std::string & /*pageName*/, SCITER_ELEMENT /*pageNav*/) +{ + return true; +} + +void SystemConfigProfiles::PageNavCreatedPage(const std::string & pageName, SCITER_ELEMENT page) +{ + if (pageName == "ManageProfiles") + { + SetupProfilesPage(page); + } +} + +void SystemConfigProfiles::PageNavPageChanged(const std::string & /*pageName*/, SCITER_ELEMENT /*pageNav*/) +{ +} + +void SystemConfigProfiles::SetupProfilesPage(SciterElement page) +{ + m_profilesPage = page; + PopulateProfiles(); +} + +bool SystemConfigProfiles::OnClick(SCITER_ELEMENT /*element*/, SCITER_ELEMENT source, uint32_t /*reason*/) +{ + SciterElement clickElem(source); + std::string role; + std::string indexAttr; + + SciterElement walk = clickElem; + while (walk.IsValid() && walk != m_page) + { + const std::string walkRole = walk.GetAttribute("role"); + const std::string walkIndex = walk.GetAttribute("data-index"); + + if (walkRole == "profile-edit" || walkRole == "profile-add" || walkRole == "profile-tile") + { + role = walkRole; + if (!walkIndex.empty()) + { + indexAttr = walkIndex; + } + break; + } + + if (indexAttr.empty() && !walkIndex.empty()) + { + indexAttr = walkIndex; + } + walk = walk.GetParent(); + } + + if (role == "profile-add") + { + if (m_enabled) + { + AddProfile(); + } + return true; + } + + if (role == "profile-edit") + { + if (m_enabled && !indexAttr.empty()) + { + OpenEditor(ProfileEditorMode::EditExistingProfile, std::stoi(indexAttr)); + } + return true; + } + + if (role == "profile-tile") + { + if (!indexAttr.empty()) + { + SelectCurrentUser(std::stoi(indexAttr)); + PopulateProfiles(); + } + return true; + } + + return true; +} + +void SystemConfigProfiles::AddProfile() +{ + if (!m_modules.IsValid()) + { + return; + } + + if (m_modules.Modules().OperatingSystem().GetProfileCount() >= HOST_PROFILE_MAX_USERS) + { + Notification::GetInstance().DisplayError("Unable to create profile. A maximum of 8 users is supported.", "Profiles"); + return; + } + + OpenEditor(ProfileEditorMode::CreateNewProfile); +} + +void SystemConfigProfiles::OpenEditor(ProfileEditorMode mode, int32_t index) +{ + if (!m_modules.IsValid()) + { + return; + } + + IOperatingSystem & os = m_modules.Modules().OperatingSystem(); + const uint32_t countBefore = os.GetProfileCount(); + + ProfileEditorDialog dialog(m_sciterUI, m_modules); + if (!dialog.Display((void *)m_window.GetHandle(), mode, index)) + { + return; + } + + const uint32_t count = os.GetProfileCount(); + if (mode == ProfileEditorMode::CreateNewProfile && count > countBefore) + { + m_currentUser = static_cast(count - 1); + } + else if (count < countBefore) + { + m_currentUser = SettingsStore::GetInstance().GetInt(NXOsSetting::CurrentUser); + } + PopulateProfiles(); +} + +void SystemConfigProfiles::PopulateProfiles() +{ + SciterElement grid = m_profilesPage.IsValid() ? m_profilesPage.GetElementByID("profileGrid") : nullptr; + if (!m_profilesPage.IsValid() || !grid.IsValid() || !m_modules.IsValid()) + { + return; + } + + grid.Clear(); + + IOperatingSystem & os = m_modules.Modules().OperatingSystem(); + const uint32_t count = os.GetProfileCount(); + if (count == 0) + { + m_currentUser = 0; + } + else if (m_currentUser < 0 || (uint32_t)m_currentUser >= count) + { + m_currentUser = 0; + } + + const int32_t current = m_currentUser; + std::string activeName; + + std::string html; + std::vector imageUris; + imageUris.reserve(count); + for (uint32_t i = 0; i < count; ++i) + { + HostProfileInfo profile{}; + if (!os.GetProfile(i, &profile)) + { + continue; + } + + const bool isCurrent = (int32_t)i == current; + const std::string imageUri = GetImageFileUrl(profile); + const std::string name = HtmlEscape(profile.username); + imageUris.push_back(imageUri); + + if (isCurrent) + { + activeName = profile.username; + } + + html += stdstr_f( + "
" + "
" + "
%s
" + "
" + "" + "" + "" + "
" + "
", + isCurrent ? " current" : "", + i, + name.c_str(), + i, + m_enabled ? "" : "disabled"); + } + + SciterElement activeLabel = m_profilesPage.GetElementByID("ActiveProfileLabel"); + if (activeLabel.IsValid()) + { + activeLabel.SetText(stdstr_f("Active profile: %s", !activeName.empty() ? activeName.c_str() : "").c_str()); + } + + if (m_enabled && count < HOST_PROFILE_MAX_USERS) + { + html += + "
" + "
+
" + "
"; + } + + grid.SetHTML((const uint8_t *)html.c_str(), html.size()); + m_sciterUI.AttachHandler(grid, IID_ICLICKSINK, (IClickSink *)this); + + for (uint32_t i = 0; i < grid.GetChildCount() && i < imageUris.size(); ++i) + { + if (imageUris[i].empty()) + { + continue; + } + + SciterElement tile = grid.GetChild(i); + if (!tile.IsValid()) + { + continue; + } + + SciterElement avatar = tile.FindFirst(".profile-tile-avatar"); + if (avatar.IsValid()) + { + avatar.SetStyleAttribute("foreground-image", ("url(" + imageUris[i] + ")").c_str()); + avatar.SetStyleAttribute("foreground-size", "100% 100%"); + avatar.SetStyleAttribute("foreground-repeat", "no-repeat"); + avatar.SetStyleAttribute("foreground-position", "50% 50%"); + } + } +} + +void SystemConfigProfiles::SelectCurrentUser(int32_t index) +{ + if (index < 0) + { + return; + } + m_currentUser = index; +} + +std::string SystemConfigProfiles::GetImageFileUrl(const HostProfileInfo & profile) const +{ + if (!m_modules.IsValid()) + { + return {}; + } + + char path[1024] = {}; + if (!m_modules.Modules().OperatingSystem().GetProfileImagePath(profile.uuid, path, sizeof(path)) || path[0] == '\0') + { + return {}; + } + return ImageDataUriFromFile(path); +} diff --git a/src/nxemu/user_interface/settings/system_config_profiles.h b/src/nxemu/user_interface/settings/system_config_profiles.h new file mode 100644 index 00000000..bb364086 --- /dev/null +++ b/src/nxemu/user_interface/settings/system_config_profiles.h @@ -0,0 +1,53 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +class SystemConfig; +class SystemModules; +enum class ProfileEditorMode; + +class SystemConfigProfiles : + public IPagesSink, + public IClickSink +{ +public: + SystemConfigProfiles(ISciterUI & sciterUI, SystemConfig & config, SystemModules & modules, ISciterWindow & window, SciterElement page); + ~SystemConfigProfiles() = default; + + void SaveSetting(void); + + // IPagesSink + bool PageNavChangeFrom(const std::string & pageName, SCITER_ELEMENT pageNav) override; + bool PageNavChangeTo(const std::string & pageName, SCITER_ELEMENT pageNav) override; + void PageNavCreatedPage(const std::string & pageName, SCITER_ELEMENT page) override; + void PageNavPageChanged(const std::string & pageName, SCITER_ELEMENT pageNav) override; + + // IClickSink + bool OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t reason) override; + +private: + SystemConfigProfiles() = delete; + SystemConfigProfiles(const SystemConfigProfiles &) = delete; + SystemConfigProfiles & operator=(const SystemConfigProfiles &) = delete; + + void SetupProfilesPage(SciterElement page); + void PopulateProfiles(); + void SelectCurrentUser(int32_t index); + void AddProfile(); + void OpenEditor(ProfileEditorMode mode, int32_t index = -1); + std::string GetImageFileUrl(const HostProfileInfo & profile) const; + + ISciterUI & m_sciterUI; + SystemConfig & m_config; + SystemModules & m_modules; + ISciterWindow & m_window; + SciterElement m_page; + SciterElement m_profilesPage; + std::shared_ptr m_pageNav; + bool m_enabled; + int32_t m_currentUser; +}; diff --git a/src/nxemu/user_interface/widgets/rom_browser.cpp b/src/nxemu/user_interface/widgets/rom_browser.cpp index 7f8589d0..e08b1c43 100644 --- a/src/nxemu/user_interface/widgets/rom_browser.cpp +++ b/src/nxemu/user_interface/widgets/rom_browser.cpp @@ -1,8 +1,8 @@ #include "rom_browser.h" +#include "user_interface/html_utils.h" #include "user_interface/sciter_main_window.h" #include "settings/ui_settings.h" #include -#include #include #include #include @@ -213,7 +213,7 @@ bool WidgetRomBrowser::RenderUI() SciterElement iconElement; iconElement.Create("img", ""); iconElement.SetAttribute("class", "rom-icon"); - iconElement.SetAttribute("src", stdstr_f("data:image/jpeg;base64,%s", base64_encode(romEntry.icon.data(), romEntry.icon.size()).c_str()).c_str()); + iconElement.SetAttribute("src", ImageDataUri(romEntry.icon.data(), romEntry.icon.size()).c_str()); romCardContents.Insert(iconElement, romCardContents.GetChildCount()); romEntry.icon.clear(); }