#include "DiabloUI/diabloui.h" #include #include #include #include #include #include #include #include #include #ifdef USE_SDL3 #include #include #include #include #include #include #else #include #endif #include #include #include #include "DiabloUI/button.h" #include "DiabloUI/scrollbar.h" #include "DiabloUI/text_input.hpp" #include "DiabloUI/ui_flags.hpp" #include "DiabloUI/ui_item.h" #include "appfat.h" #include "controls/control_mode.hpp" #include "controls/controller.h" #include "controls/input.h" #include "controls/menu_controls.h" #include "diablo.h" #include "discord/discord.h" #include "effects.h" #include "engine/clx_sprite.hpp" #include "engine/dx.h" #include "engine/load_pcx.hpp" #include "engine/palette.h" #include "engine/render/clx_render.hpp" #include "engine/render/text_render.hpp" #include "engine/sound.h" #include "engine/surface.hpp" #include "engine/ticks.hpp" #include "headless_mode.hpp" #include "hwcursor.hpp" #include "init.hpp" #include "options.h" #include "player.h" #include "sound_effect_enums.h" #include "tables/playerdat.hpp" #include "utils/algorithm/container.hpp" #include "utils/display.h" #include "utils/enum_traits.h" #include "utils/is_of.hpp" #include "utils/screen_reader.hpp" #include "utils/sdl_compat.h" #include "utils/sdl_geometry.h" #include "utils/str_cat.hpp" #include "utils/ui_fwd.h" #include "utils/utf8.hpp" #ifdef __SWITCH__ // for virtual keyboard on Switch #include "platform/switch/keyboard.h" #endif #ifdef __vita__ // for virtual keyboard on Vita #include "platform/vita/keyboard.h" #endif #ifdef __3DS__ // for virtual keyboard on 3DS #include "platform/ctr/keyboard.h" #endif namespace devilution { OptionalOwnedClxSpriteList ArtLogo; OptionalOwnedClxSpriteList DifficultyIndicator; std::array ArtFocus; OptionalOwnedClxSpriteList ArtBackgroundWidescreen; OptionalOwnedClxSpriteList ArtBackground; OptionalOwnedClxSpriteList ArtCursor; std::size_t SelectedItem = 0; namespace { OptionalOwnedClxSpriteList ArtHero; std::vector ArtHeroPortraitOrder; std::vector ArtHeroOverrides; std::size_t SelectedItemMax; std::size_t ListViewportSize = 1; std::size_t listOffset = 0; void (*gfnListFocus)(size_t value); void (*gfnListSelect)(size_t value); void (*gfnListEsc)(); void (*gfnFullscreen)(); bool (*gfnListYesNo)(); std::vector gUiItems; UiList *gUiList = nullptr; bool UiItemsWraps; std::optional UiTextInputState; bool allowEmptyTextInput = false; constexpr Uint32 ListDoubleClickTimeMs = 500; std::size_t lastListClickIndex = static_cast(-1); Uint32 lastListClickTicks = 0; struct ScrollBarState { bool upArrowPressed; bool downArrowPressed; ScrollBarState() { upArrowPressed = false; downArrowPressed = false; } } scrollBarState; std::string FormatSpokenText(const StringOrView &format, const std::vector &args) { if (args.empty()) return std::string(format.str()); std::string formatted; FMT_TRY { fmt::dynamic_format_arg_store store; for (const DrawStringFormatArg &arg : args) { const DrawStringFormatArg::Value &value = arg.value(); if (std::holds_alternative(value)) { store.push_back(std::get(value)); } else { store.push_back(std::get(value)); } } formatted = fmt::vformat(format.str(), store); } FMT_CATCH(const fmt::format_error &) { formatted = std::string(format.str()); } return formatted; } void SpeakListItem(std::size_t index, bool force = false) { if (gUiList == nullptr || index > SelectedItemMax) return; const UiListItem *pItem = gUiList->GetItem(index); if (pItem == nullptr) return; std::string text = FormatSpokenText(pItem->m_text, pItem->args); if (HasAnyOf(pItem->uiFlags, UiFlags::NeedsNextElement) && index < SelectedItemMax) { const UiListItem *pNextItem = gUiList->GetItem(index + 1); if (pNextItem != nullptr && pNextItem->m_value == pItem->m_value) { const std::string nextText = FormatSpokenText(pNextItem->m_text, pNextItem->args); if (!nextText.empty()) { if (!text.empty()) text.append(" "); text.append(nextText); } } } if (!text.empty()) SpeakText(text, force); } void AdjustListOffset(std::size_t itemIndex) { if (itemIndex >= listOffset + ListViewportSize) listOffset = itemIndex - (ListViewportSize - 1); if (itemIndex < listOffset) listOffset = itemIndex; } uint32_t fadeTc; int fadeValue; void StartUiFadeIn() { fadeValue = 0; fadeTc = 0; } void UiUpdateFadePalette() { if (fadeValue == 256) return; if (fadeValue == 0 && fadeTc == 0) { // Start the fade-in. fadeTc = SDL_GetTicks(); fadeValue = 0; BlackPalette(); // We can skip hardware cursor update for fade level 0 (everything is black). return; } const int prevFadeValue = fadeValue; fadeValue = static_cast((SDL_GetTicks() - fadeTc) / 2.083); // 32 frames @ 60hz if (fadeValue == prevFadeValue) return; if (fadeValue >= 256) { // Finish the fade-in: fadeValue = 256; fadeTc = 0; ApplyGlobalBrightness(system_palette.data(), logical_palette.data()); SystemPaletteUpdated(); if (IsHardwareCursor()) ReinitializeHardwareCursor(); return; } SDL_Color palette[256]; ApplyGlobalBrightness(palette, logical_palette.data()); ApplyFadeLevel(fadeValue, system_palette.data(), palette); SystemPaletteUpdated(); if (IsHardwareCursor()) ReinitializeHardwareCursor(); } } // namespace bool IsTextInputActive() { return UiTextInputState.has_value(); } void UiInitList(void (*fnFocus)(size_t value), void (*fnSelect)(size_t value), void (*fnEsc)(), const std::vector> &items, bool itemsWraps, void (*fnFullscreen)(), bool (*fnYesNo)(), size_t selectedItem /*= 0*/) { SelectedItem = selectedItem; SelectedItemMax = 0; ListViewportSize = 0; gfnListFocus = fnFocus; gfnListSelect = fnSelect; gfnListEsc = fnEsc; gfnFullscreen = fnFullscreen; gfnListYesNo = fnYesNo; gUiItems.clear(); for (const auto &item : items) gUiItems.push_back(item.get()); UiItemsWraps = itemsWraps; listOffset = 0; if (fnFocus != nullptr) fnFocus(selectedItem); #ifndef __SWITCH__ SDLC_StopTextInput(ghMainWnd); // input is enabled by default #endif UiScrollbar *uiScrollbar = nullptr; for (const auto &item : items) { if (item->IsType(UiType::Edit)) { auto *pItemUIEdit = static_cast(item.get()); SDL_SetTextInputArea(ghMainWnd, &item->m_rect, 0); allowEmptyTextInput = pItemUIEdit->m_allowEmpty; #ifdef __SWITCH__ switch_start_text_input(pItemUIEdit->m_hint, pItemUIEdit->m_value, pItemUIEdit->m_max_length); #elif defined(__vita__) vita_start_text_input(pItemUIEdit->m_hint, pItemUIEdit->m_value, pItemUIEdit->m_max_length); #elif defined(__3DS__) ctr_vkbdInput(pItemUIEdit->m_hint, pItemUIEdit->m_value, pItemUIEdit->m_value, pItemUIEdit->m_max_length); #else SDLC_StartTextInput(ghMainWnd); #endif UiTextInputState.emplace(TextInputState::Options { .value = pItemUIEdit->m_value, .cursor = &pItemUIEdit->m_cursor, .maxLength = pItemUIEdit->m_max_length, }); if (!pItemUIEdit->m_hint.empty()) SpeakText(pItemUIEdit->m_hint, /*force=*/true); } else if (item->IsType(UiType::List)) { auto *uiList = static_cast(item.get()); SelectedItemMax = std::max(uiList->m_vecItems.size() - 1, static_cast(0)); ListViewportSize = uiList->viewportSize; gUiList = uiList; if (selectedItem <= SelectedItemMax && HasAnyOf(uiList->GetItem(selectedItem)->uiFlags, UiFlags::NeedsNextElement)) AdjustListOffset(selectedItem + 1); SpeakListItem(selectedItem); } else if (item->IsType(UiType::Scrollbar)) { uiScrollbar = static_cast(item.get()); } } AdjustListOffset(selectedItem); if (uiScrollbar != nullptr) { if (ListViewportSize >= static_cast(SelectedItemMax + 1)) { uiScrollbar->Hide(); } else { uiScrollbar->Show(); } } } void UiRenderListItems() { UiRenderItems(gUiItems); } void UiInitList_clear() { SelectedItem = 0; SelectedItemMax = 0; ListViewportSize = 1; gfnListFocus = nullptr; gfnListSelect = nullptr; gfnListEsc = nullptr; gfnFullscreen = nullptr; gfnListYesNo = nullptr; gUiList = nullptr; gUiItems.clear(); UiItemsWraps = false; } void UiPlayMoveSound() { effects_play_sound(SfxID::MenuMove); } void UiPlaySelectSound() { effects_play_sound(SfxID::MenuSelect); } namespace { void UiFocus(std::size_t itemIndex, bool checkUp, bool ignoreItemsWraps = false) { if (SelectedItem == itemIndex) return; AdjustListOffset(itemIndex); const auto *pItem = gUiList->GetItem(itemIndex); while (HasAnyOf(pItem->uiFlags, UiFlags::ElementHidden | UiFlags::ElementDisabled)) { if (checkUp) { if (itemIndex > 0) itemIndex -= 1; else if (UiItemsWraps && !ignoreItemsWraps) itemIndex = SelectedItemMax; else checkUp = false; } else { if (itemIndex < SelectedItemMax) itemIndex += 1; else if (UiItemsWraps && !ignoreItemsWraps) itemIndex = 0; else checkUp = true; } pItem = gUiList->GetItem(itemIndex); } SpeakListItem(itemIndex); if (HasAnyOf(pItem->uiFlags, UiFlags::NeedsNextElement)) AdjustListOffset(itemIndex + 1); AdjustListOffset(itemIndex); SelectedItem = itemIndex; UiPlayMoveSound(); if (gfnListFocus != nullptr) gfnListFocus(itemIndex); } void UiFocusUp() { if (SelectedItem > 0) UiFocus(SelectedItem - 1, true); else if (UiItemsWraps) UiFocus(SelectedItemMax, true); } void UiFocusDown() { if (SelectedItem < SelectedItemMax) UiFocus(SelectedItem + 1, false); else if (UiItemsWraps) UiFocus(0, false); } // UiFocusPageUp/Down mimics the slightly weird behaviour of actual Diablo. void UiFocusPageUp() { if (listOffset == 0) { UiFocus(0, true, true); } else { const std::size_t relpos = SelectedItem - listOffset; std::size_t prevPageStart = SelectedItem - relpos; if (prevPageStart >= ListViewportSize) prevPageStart -= ListViewportSize; else prevPageStart = 0; AdjustListOffset(prevPageStart); UiFocus(listOffset + relpos, true, true); } } void UiFocusPageDown() { if (listOffset + ListViewportSize > static_cast(SelectedItemMax)) { UiFocus(SelectedItemMax, false, true); } else { const std::size_t relpos = SelectedItem - listOffset; std::size_t nextPageEnd = SelectedItem + (ListViewportSize - relpos - 1); if (nextPageEnd + ListViewportSize <= static_cast(SelectedItemMax)) nextPageEnd += ListViewportSize; else nextPageEnd = SelectedItemMax; AdjustListOffset(nextPageEnd); UiFocus(listOffset + relpos, false, true); } } bool HandleMenuAction(MenuAction menuAction) { switch (menuAction) { case MenuAction_SELECT: UiFocusNavigationSelect(); return true; case MenuAction_UP: UiFocusUp(); return true; case MenuAction_DOWN: UiFocusDown(); return true; case MenuAction_PAGE_UP: UiFocusPageUp(); return true; case MenuAction_PAGE_DOWN: UiFocusPageDown(); return true; case MenuAction_DELETE: UiFocusNavigationYesNo(); return true; case MenuAction_BACK: if (gfnListEsc == nullptr) return false; UiFocusNavigationEsc(); return true; default: return false; } } void UiOnBackgroundChange() { StartUiFadeIn(); if (IsHardwareCursorEnabled() && ArtCursor && ControlDevice == ControlTypes::KeyboardAndMouse && GetCurrentCursorInfo().type() != CursorType::UserInterface) { SetHardwareCursor(CursorInfo::UserInterfaceCursor()); } // It may take some time to get to the first `UiFadeIn()` call from here // if there is non-trivial initialization work, such as loading the list // of single-player characters. // // Black out the screen immediately to make it appear more smooth. SDL_FillSurfaceRect(DiabloUiSurface(), nullptr, 0); if (DiabloUiSurface() == PalSurface) BltFast(nullptr, nullptr); RenderPresent(); } void UiFocusNavigation(SDL_Event *event) { switch (event->type) { case SDL_EVENT_KEY_UP: case SDL_EVENT_MOUSE_BUTTON_UP: case SDL_EVENT_MOUSE_MOTION: case SDL_EVENT_JOYSTICK_BUTTON_UP: case SDL_EVENT_JOYSTICK_AXIS_MOTION: case SDL_EVENT_JOYSTICK_BALL_MOTION: case SDL_EVENT_JOYSTICK_HAT_MOTION: #ifndef USE_SDL1 case SDL_EVENT_MOUSE_WHEEL: case SDL_EVENT_FINGER_UP: case SDL_EVENT_FINGER_MOTION: case SDL_EVENT_GAMEPAD_BUTTON_UP: case SDL_EVENT_GAMEPAD_AXIS_MOTION: #endif #ifdef USE_SDL3 case SDL_EVENT_WINDOW_EXPOSED: #else #ifndef USE_SDL1 case SDL_WINDOWEVENT: #endif case SDL_SYSWMEVENT: #endif mainmenu_restart_repintro(); break; default: break; } bool menuActionHandled = false; for (const MenuAction menuAction : GetMenuActions(*event)) menuActionHandled |= HandleMenuAction(menuAction); if (menuActionHandled) return; #ifndef USE_SDL1 if (event->type == SDL_EVENT_MOUSE_WHEEL) { if (SDLC_EventWheelIntY(*event) > 0) { UiFocusUp(); } else if (SDLC_EventWheelIntY(*event) < 0) { UiFocusDown(); } return; } #else if (event->type == SDL_MOUSEBUTTONDOWN) { switch (event->button.button) { case SDL_BUTTON_WHEELUP: UiFocusUp(); return; case SDL_BUTTON_WHEELDOWN: UiFocusDown(); return; } } #endif if (UiTextInputState.has_value() && HandleTextInputEvent(*event, *UiTextInputState)) { return; } if (IsAnyOf(event->type, SDL_EVENT_MOUSE_BUTTON_DOWN, SDL_EVENT_MOUSE_BUTTON_UP) && UiItemMouseEvents(event, gUiItems)) { return; } } } // namespace void UiHandleEvents(SDL_Event *event) { if (event->type == SDL_EVENT_MOUSE_MOTION) { MousePosition = { SDLC_EventMotionIntX(*event), SDLC_EventMotionIntY(*event) }; return; } if (event->type == SDL_EVENT_KEY_DOWN && SDLC_EventKey(*event) == SDLK_RETURN) { const auto *state = SDLC_GetKeyState(); if (state[SDLC_KEYSTATE_LALT] != 0 || state[SDLC_KEYSTATE_RALT] != 0) { GetOptions().Graphics.fullscreen.SetValue(!IsFullScreen()); SaveOptions(); if (gfnFullscreen != nullptr) gfnFullscreen(); return; } } if (event->type == SDL_EVENT_QUIT) { diablo_quit(0); } #ifndef USE_SDL1 HandleControllerAddedOrRemovedEvent(*event); #ifdef USE_SDL3 switch (event->type) { case SDL_EVENT_WINDOW_SHOWN: case SDL_EVENT_WINDOW_EXPOSED: case SDL_EVENT_WINDOW_RESTORED: gbActive = true; break; case SDL_EVENT_WINDOW_HIDDEN: case SDL_EVENT_WINDOW_MINIMIZED: gbActive = false; break; case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: DoReinitializeHardwareCursor(); break; case SDL_EVENT_WINDOW_FOCUS_LOST: if (*GetOptions().Gameplay.pauseOnFocusLoss) music_mute(); break; case SDL_EVENT_WINDOW_FOCUS_GAINED: if (*GetOptions().Gameplay.pauseOnFocusLoss) diablo_focus_unpause(); break; default: break; } #else if (event->type == SDL_WINDOWEVENT) { if (IsAnyOf(event->window.event, SDL_WINDOWEVENT_SHOWN, SDL_WINDOWEVENT_EXPOSED, SDL_WINDOWEVENT_RESTORED)) { gbActive = true; } else if (IsAnyOf(event->window.event, SDL_WINDOWEVENT_HIDDEN, SDL_WINDOWEVENT_MINIMIZED)) { gbActive = false; } else if (event->window.event == SDL_WINDOWEVENT_SIZE_CHANGED) { // We reinitialize immediately (by calling `DoReinitializeHardwareCursor` instead of `ReinitializeHardwareCursor`) // because the cursor's Enabled state may have changed, resulting in changes to visibility. // // For example, if the previous size was too large for a hardware cursor then it was invisible // but may now become visible. DoReinitializeHardwareCursor(); } else if (event->window.event == SDL_WINDOWEVENT_FOCUS_LOST && *GetOptions().Gameplay.pauseOnFocusLoss) { music_mute(); } else if (event->window.event == SDL_WINDOWEVENT_FOCUS_GAINED && *GetOptions().Gameplay.pauseOnFocusLoss) { diablo_focus_unpause(); } } #endif #else if (event->type == SDL_ACTIVEEVENT && (event->active.state & SDL_APPINPUTFOCUS) != 0) { if (event->active.gain == 0) music_mute(); else diablo_focus_unpause(); } #endif } void UiFocusNavigationSelect() { UiPlaySelectSound(); if (UiTextInputState.has_value()) { if (!allowEmptyTextInput && UiTextInputState->empty()) { return; } #ifndef __SWITCH__ SDLC_StopTextInput(ghMainWnd); #endif UiTextInputState = std::nullopt; } if (gfnListSelect != nullptr) gfnListSelect(SelectedItem); } void UiFocusNavigationEsc() { UiPlaySelectSound(); if (UiTextInputState.has_value()) { #ifndef __SWITCH__ SDLC_StopTextInput(ghMainWnd); #endif UiTextInputState = std::nullopt; } if (gfnListEsc != nullptr) gfnListEsc(); } void UiFocusNavigationYesNo() { if (gfnListYesNo == nullptr) return; if (gfnListYesNo()) UiPlaySelectSound(); } namespace { bool IsInsideRect(const SDL_Event &event, const SDL_Rect &rect) { const SDL_Point point = { SDLC_EventButtonIntX(event), SDLC_EventButtonIntY(event) }; return SDLC_PointInRect(&point, &rect); } void LoadHeros() { constexpr unsigned PortraitHeight = 76; ArtHero = LoadPcxSpriteList("ui_art\\heros", -static_cast(PortraitHeight)); if (!ArtHero) return; const uint16_t numPortraits = ClxSpriteList { *ArtHero }.numSprites(); ArtHeroPortraitOrder.resize(GetNumPlayerClasses() + 1); for (size_t i = 0; i < GetNumPlayerClasses(); ++i) { const PlayerData &playerClassData = GetPlayerDataForClass(static_cast(i)); ArtHeroPortraitOrder[i] = playerClassData.portrait; } ArtHeroPortraitOrder.back() = 3; if (numPortraits >= 6) { ArtHeroPortraitOrder.back() = 5; } ArtHeroOverrides.resize(GetNumPlayerClasses() + 1); for (size_t i = 0; i <= GetNumPlayerClasses(); ++i) { char portraitPath[18]; *BufCopy(portraitPath, "ui_art\\hero", i) = '\0'; ArtHeroOverrides[i] = LoadPcx(portraitPath, /*transparentColor=*/std::nullopt, /*outPalette=*/nullptr, /*logError=*/false); } } void LoadUiGFX() { ArtLogo = LoadPcxSpriteList("ui_art\\hf_logo2", /*numFrames=*/16, /*transparentColor=*/0, nullptr, false); if (!ArtLogo.has_value()) { ArtLogo = LoadPcxSpriteList("ui_art\\smlogo", /*numFrames=*/15, /*transparentColor=*/250); } DifficultyIndicator = LoadPcx("ui_art\\r1_gry", /*transparentColor=*/0); ArtFocus[FOCUS_SMALL] = LoadPcxSpriteList("ui_art\\focus16", /*numFrames=*/8, /*transparentColor=*/250); ArtFocus[FOCUS_MED] = LoadPcxSpriteList("ui_art\\focus", /*numFrames=*/8, /*transparentColor=*/250); ArtFocus[FOCUS_BIG] = LoadPcxSpriteList("ui_art\\focus42", /*numFrames=*/8, /*transparentColor=*/250); ArtCursor = LoadPcx("ui_art\\cursor", /*transparentColor=*/0); LoadHeros(); } } // namespace ClxSprite UiGetHeroDialogSprite(size_t heroClassIndex) { return ArtHeroOverrides[heroClassIndex] ? (*ArtHeroOverrides[heroClassIndex])[0] : (*ArtHero)[ArtHeroPortraitOrder[heroClassIndex]]; } void UnloadUiGFX() { ArtHero = std::nullopt; for (OptionalOwnedClxSpriteList &override : ArtHeroOverrides) override = std::nullopt; ArtCursor = std::nullopt; for (auto &art : ArtFocus) art = std::nullopt; ArtLogo = std::nullopt; DifficultyIndicator = std::nullopt; } void UiInitialize() { LoadUiGFX(); if (ArtCursor) { if (!SDLC_HideCursor()) ErrSdl(); } } void UiDestroy() { UnloadFonts(); UnloadUiGFX(); } bool UiValidPlayerName(std::string_view name) { if (name.empty()) return false; // Currently only allow saving PlayerNameLength bytes as a player name, so if the name is too long we'd have to truncate it. // That said the input buffer is only 16 bytes long... if (name.size() > PlayerNameLength) return false; if (name.find_first_of(",<>%&\\\"?*#/: ") != name.npos) return false; // Only basic latin alphabet is supported for multiplayer characters to avoid rendering issues for players who do // not have fonts.mpq installed if (!c_all_of(name, IsBasicLatin)) return false; const std::string_view bannedNames[] = { "gvdl", "dvou", "tiju", "cjudi", "bttipmf", "ojhhfs", "cmj{{bse", "benjo", }; std::string buffer { name }; for (char &character : buffer) character++; const std::string_view tempName { buffer }; for (const std::string_view bannedName : bannedNames) { if (tempName.find(bannedName) != tempName.npos) return false; } return true; } Sint16 GetCenterOffset(Sint16 w, Sint16 bw) { if (bw == 0) { bw = gnScreenWidth; } return (bw - w) / 2; } void UiLoadDefaultPalette() { LoadPalette("ui_art\\diablo.pal"); UpdateSystemPalette(logical_palette); } bool UiLoadBlackBackground() { ArtBackground = std::nullopt; UiLoadDefaultPalette(); UiOnBackgroundChange(); return true; } void LoadBackgroundArt(const char *pszFile, int frames) { ArtBackground = std::nullopt; ArtBackground = LoadPcxSpriteList(pszFile, static_cast(frames), /*transparentColor=*/std::nullopt, logical_palette.data()); if (!ArtBackground) return; UpdateSystemPalette(logical_palette); UiOnBackgroundChange(); } void UiAddBackground(std::vector> *vecDialog) { const SDL_Rect rect = MakeSdlRect(0, GetUIRectangle().position.y, 0, 0); if (ArtBackgroundWidescreen) { vecDialog->push_back(std::make_unique((*ArtBackgroundWidescreen)[0], rect, UiFlags::AlignCenter)); } if (ArtBackground) { vecDialog->push_back(std::make_unique((*ArtBackground)[0], rect, UiFlags::AlignCenter)); } } void UiAddLogo(std::vector> *vecDialog, int y) { vecDialog->push_back(std::make_unique( *ArtLogo, MakeSdlRect(0, y, 0, 0), UiFlags::AlignCenter)); } void UiFadeIn() { if (HeadlessMode) return; UiUpdateFadePalette(); if (DiabloUiSurface() == PalSurface) { BltFast(nullptr, nullptr); } RenderPresent(); } namespace { ClxSpriteList GetListSelectorSprites(int itemHeight) { int size; if (itemHeight >= 42) { size = FOCUS_BIG; } else if (itemHeight >= 30) { size = FOCUS_MED; } else { size = FOCUS_SMALL; } return *ArtFocus[size]; } } // namespace void DrawSelector(const SDL_Rect &rect) { const ClxSpriteList sprites = GetListSelectorSprites(rect.h); const ClxSprite sprite = sprites[GetAnimationFrame(sprites.numSprites())]; // TODO FOCUS_MED appears higher than the box const int y = rect.y + ((rect.h - static_cast(sprite.height())) / 2); const Surface &out = Surface(DiabloUiSurface()); RenderClxSprite(out, sprite, { rect.x, y }); RenderClxSprite(out, sprite, { rect.x + rect.w - sprite.width(), y }); } void UiClearScreen() { if (!ArtBackground || gnScreenWidth > (*ArtBackground)[0].width() || gnScreenHeight > (*ArtBackground)[0].height()) { SDL_FillSurfaceRect(DiabloUiSurface(), nullptr, 0); } } void UiPollAndRender(std::optional> eventHandler) { SDL_Event event; while (PollEvent(&event)) { if (eventHandler && (*eventHandler)(event)) continue; if (!SDLC_ConvertEventToRenderCoordinates(renderer, &event)) { LogWarn(LogCategory::Application, "SDL_ConvertEventToRenderCoordinates: {}", SDL_GetError()); SDL_ClearError(); } UiFocusNavigation(&event); UiHandleEvents(&event); } HandleMenuAction(GetMenuHeldUpDownAction()); UiRenderListItems(); DrawMouse(); UiFadeIn(); // Must happen after at least one call to `UiFadeIn` with non-zero fadeValue. // `UiFadeIn` reinitializes the hardware cursor only for fadeValue > 0. if (IsHardwareCursor() && fadeValue != 0) SetHardwareCursorVisible(ControlDevice == ControlTypes::KeyboardAndMouse); #ifdef __3DS__ // Keyboard blocks until input is finished // so defer until after render and fade-in ctr_vkbdFlush(); #endif discord_manager::UpdateMenu(); } namespace { void Render(const UiText &uiText) { const Surface &out = Surface(DiabloUiSurface()); DrawString(out, uiText.GetText(), MakeRectangle(uiText.m_rect), { .flags = uiText.GetFlags() | UiFlags::FontSizeDialog }); } void Render(const UiArtText &uiArtText) { const Surface &out = Surface(DiabloUiSurface()); DrawString(out, uiArtText.GetText(), MakeRectangle(uiArtText.m_rect), { .flags = uiArtText.GetFlags(), .spacing = uiArtText.GetSpacing(), .lineHeight = uiArtText.GetLineHeight() }); } void Render(const UiImageClx &uiImage) { const ClxSprite sprite = uiImage.sprite(); int x = uiImage.m_rect.x; if (uiImage.isCentered()) { x += GetCenterOffset(sprite.width(), uiImage.m_rect.w); } RenderClxSprite(Surface(DiabloUiSurface()), sprite, { x, uiImage.m_rect.y }); } void Render(const UiImageAnimatedClx &uiImage) { const ClxSprite sprite = uiImage.sprite(GetAnimationFrame(uiImage.numFrames())); int x = uiImage.m_rect.x; if (uiImage.isCentered()) { x += GetCenterOffset(sprite.width(), uiImage.m_rect.w); } RenderClxSprite(Surface(DiabloUiSurface()), sprite, { x, uiImage.m_rect.y }); } void Render(const UiArtTextButton &uiButton) { const Surface &out = Surface(DiabloUiSurface()); DrawString(out, uiButton.GetText(), MakeRectangle(uiButton.m_rect), { .flags = uiButton.GetFlags() }); } void Render(const UiList &uiList) { const Surface &out = Surface(DiabloUiSurface()); for (std::size_t i = listOffset; i < uiList.m_vecItems.size() && (i - listOffset) < ListViewportSize; ++i) { const SDL_Rect rect = uiList.itemRect(static_cast(i - listOffset)); const UiListItem &item = *uiList.GetItem(i); if (i == SelectedItem) DrawSelector(rect); const Rectangle rectangle = MakeRectangle(rect).inset( Displacement(GetListSelectorSprites(rect.h)[0].width(), 0)); const UiFlags uiFlags = uiList.GetFlags() | item.uiFlags; const GameFontTables fontSize = GetFontSizeFromUiFlags(uiFlags); std::string_view text = item.m_text.str(); while (GetLineWidth(text, fontSize, 1) > rectangle.size.width) { text = std::string_view(text.data(), FindLastUtf8Symbols(text)); } if (item.args.empty()) { DrawString(out, text, rectangle, { .flags = uiFlags, .spacing = uiList.GetSpacing() }); } else { DrawStringWithColors(out, text, item.args, rectangle, { .flags = uiFlags, .spacing = uiList.GetSpacing() }); } } } void Render(const UiScrollbar &uiSb) { const Surface out = Surface(DiabloUiSurface()); // Bar background (tiled): { const int bgY = uiSb.m_rect.y + uiSb.m_arrow[0].height(); const int bgH = DownArrowRect(uiSb).y - bgY; const Surface backgroundOut = out.subregion(uiSb.m_rect.x, bgY, ScrollBarBgWidth, bgH); int y = 0; while (y < bgH) { RenderClxSprite(backgroundOut, uiSb.m_bg, { 0, y }); y += uiSb.m_bg.height(); } } // Arrows: { const SDL_Rect rect = UpArrowRect(uiSb); const auto frame = static_cast(scrollBarState.upArrowPressed ? ScrollBarArrowFrame_UP_ACTIVE : ScrollBarArrowFrame_UP); RenderClxSprite(out.subregion(rect.x, 0, ScrollBarArrowWidth, out.h()), uiSb.m_arrow[frame], { 0, rect.y }); } { const SDL_Rect rect = DownArrowRect(uiSb); const auto frame = static_cast(scrollBarState.downArrowPressed ? ScrollBarArrowFrame_DOWN_ACTIVE : ScrollBarArrowFrame_DOWN); RenderClxSprite(out.subregion(rect.x, 0, ScrollBarArrowWidth, out.h()), uiSb.m_arrow[frame], { 0, rect.y }); } // Thumb: if (SelectedItemMax > 0) { const SDL_Rect rect = ThumbRect(uiSb, SelectedItem, SelectedItemMax + 1); RenderClxSprite(out, uiSb.m_thumb, { rect.x, rect.y }); } } void Render(const UiEdit &uiEdit) { DrawSelector(uiEdit.m_rect); // To simulate padding we inset the region used to draw text in an edit control const Rectangle rect = MakeRectangle(uiEdit.m_rect).inset({ 43, 1 }); const Surface &out = Surface(DiabloUiSurface()); DrawString(out, uiEdit.m_value, rect, { .flags = uiEdit.GetFlags(), .cursorPosition = static_cast(uiEdit.m_cursor.position), .highlightRange = { static_cast(uiEdit.m_cursor.selection.begin), static_cast(uiEdit.m_cursor.selection.end) }, .highlightColor = 126, }); } bool HandleMouseEventArtTextButton(const SDL_Event &event, const UiArtTextButton *uiButton) { if (event.type != SDL_EVENT_MOUSE_BUTTON_UP || event.button.button != SDL_BUTTON_LEFT) { return false; } uiButton->Activate(); return true; } bool HandleMouseEventList(const SDL_Event &event, UiList *uiList) { if (event.button.button != SDL_BUTTON_LEFT) return false; if (!IsAnyOf(event.type, SDL_EVENT_MOUSE_BUTTON_UP, SDL_EVENT_MOUSE_BUTTON_DOWN)) { return false; } std::size_t index = uiList->indexAt(event.button.y); if (event.type == SDL_EVENT_MOUSE_BUTTON_DOWN) { uiList->Press(index); return true; } if (event.type == SDL_EVENT_MOUSE_BUTTON_UP && !uiList->IsPressed(index)) { return false; } index += listOffset; const bool hasFocusCallback = gfnListFocus != nullptr; const Uint32 ticksNow = SDL_GetTicks(); const bool recentlyClickedSameItem = hasFocusCallback && lastListClickIndex == index && ticksNow - lastListClickTicks <= ListDoubleClickTimeMs; #ifndef USE_SDL1 const bool sdlReportedDoubleClick = event.button.clicks >= 2; #else const bool sdlReportedDoubleClick = false; #endif const bool doubleClicked = recentlyClickedSameItem || sdlReportedDoubleClick; lastListClickIndex = index; lastListClickTicks = ticksNow; if (hasFocusCallback && SelectedItem != index) { UiFocus(index, true, false); return true; } if (hasFocusCallback && !doubleClicked) { return true; } if (HasAnyOf(uiList->GetItem(index)->uiFlags, UiFlags::ElementHidden | UiFlags::ElementDisabled)) return false; SelectedItem = index; UiFocusNavigationSelect(); return true; } bool HandleMouseEventScrollBar(const SDL_Event &event, const UiScrollbar *uiSb) { if (event.button.button != SDL_BUTTON_LEFT) return false; if (event.type == SDL_EVENT_MOUSE_BUTTON_UP) { if (scrollBarState.upArrowPressed && IsInsideRect(event, UpArrowRect(*uiSb))) { UiFocusUp(); return true; } if (scrollBarState.downArrowPressed && IsInsideRect(event, DownArrowRect(*uiSb))) { UiFocusDown(); return true; } } else if (event.type == SDL_EVENT_MOUSE_BUTTON_DOWN) { if (IsInsideRect(event, BarRect(*uiSb))) { // Scroll up or down based on thumb position. const SDL_Rect thumbRect = ThumbRect(*uiSb, SelectedItem, SelectedItemMax + 1); if (event.button.y < thumbRect.y) { UiFocusPageUp(); } else if (event.button.y > thumbRect.y + thumbRect.h) { UiFocusPageDown(); } return true; } if (IsInsideRect(event, UpArrowRect(*uiSb))) { scrollBarState.upArrowPressed = true; return true; } if (IsInsideRect(event, DownArrowRect(*uiSb))) { scrollBarState.downArrowPressed = true; return true; } } return false; } bool HandleMouseEvent(const SDL_Event &event, UiItemBase *item) { if (item->IsNotInteractive() || !IsInsideRect(event, item->m_rect)) return false; switch (item->GetType()) { case UiType::ArtTextButton: return HandleMouseEventArtTextButton(event, static_cast(item)); case UiType::Button: return HandleMouseEventButton(event, static_cast(item)); case UiType::List: return HandleMouseEventList(event, static_cast(item)); case UiType::Scrollbar: return HandleMouseEventScrollBar(event, static_cast(item)); default: return false; } } } // namespace void UiRenderItem(const UiItemBase &item) { if (item.IsHidden()) return; switch (item.GetType()) { case UiType::Text: Render(static_cast(item)); break; case UiType::ArtText: Render(static_cast(item)); break; case UiType::ImageClx: Render(static_cast(item)); break; case UiType::ImageAnimatedClx: Render(static_cast(item)); break; case UiType::ArtTextButton: Render(static_cast(item)); break; case UiType::Button: RenderButton(static_cast(item)); break; case UiType::List: Render(static_cast(item)); break; case UiType::Scrollbar: Render(static_cast(item)); break; case UiType::Edit: Render(static_cast(item)); break; } } void UiRenderItems(const std::vector &items) { for (const UiItemBase *item : items) UiRenderItem(*item); } void UiRenderItems(const std::vector> &items) { for (const std::unique_ptr &item : items) UiRenderItem(*item); } bool UiItemMouseEvents(SDL_Event *event, const std::vector &items) { if (items.empty()) { return false; } bool handled = false; for (const auto &item : items) { if (HandleMouseEvent(*event, item)) { handled = true; break; } } if (event->type == SDL_EVENT_MOUSE_BUTTON_UP && event->button.button == SDL_BUTTON_LEFT) { scrollBarState.downArrowPressed = scrollBarState.upArrowPressed = false; for (const auto &item : items) { if (item->IsType(UiType::Button)) { HandleGlobalMouseUpButton(static_cast(item)); } else if (item->IsType(UiType::List)) { static_cast(item)->Release(); } } } return handled; } bool UiItemMouseEvents(SDL_Event *event, const std::vector> &items) { if (items.empty()) { return false; } bool handled = false; for (const auto &item : items) { if (HandleMouseEvent(*event, item.get())) { handled = true; break; } } if (event->type == SDL_EVENT_MOUSE_BUTTON_UP && event->button.button == SDL_BUTTON_LEFT) { scrollBarState.downArrowPressed = scrollBarState.upArrowPressed = false; for (const auto &item : items) { if (item->IsType(UiType::Button)) { HandleGlobalMouseUpButton(static_cast(item.get())); } else if (item->IsType(UiType::List)) { static_cast(item.get())->Release(); } } } return handled; } void DrawMouse() { if (ControlDevice != ControlTypes::KeyboardAndMouse || IsHardwareCursor() || !ArtCursor) return; RenderClxSprite(Surface(DiabloUiSurface()), (*ArtCursor)[0], MousePosition); } } // namespace devilution