diff --git a/include/rex/input/input_driver.h b/include/rex/input/input_driver.h index b579ef4..08beb54 100644 --- a/include/rex/input/input_driver.h +++ b/include/rex/input/input_driver.h @@ -34,6 +34,15 @@ class InputDriver { virtual X_RESULT GetCapabilities(uint32_t user_index, uint32_t flags, X_INPUT_CAPABILITIES* out_caps) = 0; virtual X_RESULT GetState(uint32_t user_index, X_INPUT_STATE* out_state) = 0; + // Raw pad state for host UI overlays (settings navigation). Unlike + // GetState, this must NOT be gated on is_active() - it is exactly the path + // used while guest input is suppressed by an open overlay. Drivers that + // self-gate GetState override this; drivers that emulate a pad from + // keyboard/mouse override it to report not-connected (the host UI already + // receives keyboard/mouse directly). + virtual X_RESULT GetStateUi(uint32_t user_index, X_INPUT_STATE* out_state) { + return GetState(user_index, out_state); + } virtual X_RESULT SetState(uint32_t user_index, X_INPUT_VIBRATION* vibration) = 0; virtual X_RESULT GetKeystroke(uint32_t user_index, uint32_t flags, X_INPUT_KEYSTROKE* out_keystroke) = 0; diff --git a/include/rex/input/input_system.h b/include/rex/input/input_system.h index 5c24f8d..3b060a9 100644 --- a/include/rex/input/input_system.h +++ b/include/rex/input/input_system.h @@ -41,6 +41,11 @@ class InputSystem : public system::IInputSystem { X_RESULT GetCapabilities(uint32_t user_index, uint32_t flags, X_INPUT_CAPABILITIES* out_caps); X_RESULT GetState(uint32_t user_index, X_INPUT_STATE* out_state); + // Merged raw pad state for host UI overlays: all connected pads across all + // drivers and user slots, bypassing the is_active suppression and the + // menu-chord edge tracking of the guest-facing GetState. Safe to call from + // the UI thread. Returns false when no pad is connected. + bool GetUiGamepadState(X_INPUT_GAMEPAD* out_gamepad); X_RESULT SetState(uint32_t user_index, X_INPUT_VIBRATION* vibration); X_RESULT GetKeystroke(uint32_t user_index, uint32_t flags, X_INPUT_KEYSTROKE* out_keystroke); diff --git a/include/rex/input/mnk/mnk_input_driver.h b/include/rex/input/mnk/mnk_input_driver.h index 80c7b77..2432d01 100644 --- a/include/rex/input/mnk/mnk_input_driver.h +++ b/include/rex/input/mnk/mnk_input_driver.h @@ -32,6 +32,11 @@ class MnkInputDriver final : public InputDriver, X_RESULT GetCapabilities(uint32_t user_index, uint32_t flags, X_INPUT_CAPABILITIES* out_caps) override; X_RESULT GetState(uint32_t user_index, X_INPUT_STATE* out_state) override; + // Keyboard/mouse reach the host UI directly; exposing the emulated pad here + // would double-apply every keypress in overlay navigation. + X_RESULT GetStateUi(uint32_t, X_INPUT_STATE*) override { + return X_ERROR_DEVICE_NOT_CONNECTED; + } X_RESULT SetState(uint32_t user_index, X_INPUT_VIBRATION* vibration) override; X_RESULT GetKeystroke(uint32_t user_index, uint32_t flags, X_INPUT_KEYSTROKE* out_keystroke) override; diff --git a/include/rex/input/nop/nop_input_driver.h b/include/rex/input/nop/nop_input_driver.h index c3e46cf..8e7489f 100644 --- a/include/rex/input/nop/nop_input_driver.h +++ b/include/rex/input/nop/nop_input_driver.h @@ -24,6 +24,11 @@ class NopInputDriver final : public InputDriver { X_RESULT GetCapabilities(uint32_t user_index, uint32_t flags, X_INPUT_CAPABILITIES* out_caps) override; X_RESULT GetState(uint32_t user_index, X_INPUT_STATE* out_state) override; + // The spoofed always-connected pad exists only so the guest doesn't pause; + // it must not read as a real pad to host UI overlays. + X_RESULT GetStateUi(uint32_t, X_INPUT_STATE*) override { + return X_ERROR_DEVICE_NOT_CONNECTED; + } X_RESULT SetState(uint32_t user_index, X_INPUT_VIBRATION* vibration) override; X_RESULT GetKeystroke(uint32_t user_index, uint32_t flags, X_INPUT_KEYSTROKE* out_keystroke) override; diff --git a/include/rex/input/sdl/sdl_input_driver.h b/include/rex/input/sdl/sdl_input_driver.h index 34ed6d1..5e4571d 100644 --- a/include/rex/input/sdl/sdl_input_driver.h +++ b/include/rex/input/sdl/sdl_input_driver.h @@ -39,6 +39,7 @@ class SDLInputDriver final : public InputDriver, public rex::ui::WindowListener X_RESULT GetCapabilities(uint32_t user_index, uint32_t flags, X_INPUT_CAPABILITIES* out_caps) override; X_RESULT GetState(uint32_t user_index, X_INPUT_STATE* out_state) override; + X_RESULT GetStateUi(uint32_t user_index, X_INPUT_STATE* out_state) override; X_RESULT SetState(uint32_t user_index, X_INPUT_VIBRATION* vibration) override; X_RESULT GetKeystroke(uint32_t user_index, uint32_t flags, X_INPUT_KEYSTROKE* out_keystroke) override; diff --git a/include/rex/ui/fonts_inter.h b/include/rex/ui/fonts_inter.h new file mode 100644 index 0000000..94dcdf5 --- /dev/null +++ b/include/rex/ui/fonts_inter.h @@ -0,0 +1,17 @@ +/** + * @file rex/ui/fonts_inter.h + * + * @brief Embedded Inter font (Latin subset) for styled overlays. + * + * Inter is licensed under the SIL Open Font License 1.1 - full license text + * in fonts_inter.cpp. The data is in Dear ImGui's compressed base85 format + * for ImFontAtlas::AddFontFromMemoryCompressedBase85TTF. + */ +#pragma once + +namespace rex::ui { + +const char* GetInterRegularCompressedBase85(); +const char* GetInterSemiBoldCompressedBase85(); + +} // namespace rex::ui diff --git a/include/rex/ui/imgui_drawer.h b/include/rex/ui/imgui_drawer.h index 906e3d9..7f73981 100644 --- a/include/rex/ui/imgui_drawer.h +++ b/include/rex/ui/imgui_drawer.h @@ -25,6 +25,7 @@ #include struct ImDrawData; +struct ImFont; struct ImFontAtlas; struct ImGuiContext; struct ImGuiIO; @@ -45,6 +46,13 @@ class ImGuiDrawer : public WindowInputListener, public UIDrawer { ImGuiIO& GetIO(); bool HasDialogs() const { return !dialogs_.empty(); } + // Proportional system UI font for styled overlays (and a heavier weight for + // headings), loaded in SetupFonts from per-platform system font paths. + // Nullptr when no suitable system font was found - callers fall back to the + // default font (PushFont(nullptr, size)). + ImFont* ui_font() const { return ui_font_; } + ImFont* ui_font_semibold() const { return ui_font_semibold_; } + void AddDialog(ImGuiDialog* dialog); void RemoveDialog(ImGuiDialog* dialog); @@ -103,6 +111,10 @@ class ImGuiDrawer : public WindowInputListener, public UIDrawer { ImGuiContext* internal_state_ = nullptr; + // System UI fonts (see ui_font()). Owned by the ImGui font atlas. + ImFont* ui_font_ = nullptr; + ImFont* ui_font_semibold_ = nullptr; + // All currently-attached dialogs that get drawn. std::vector dialogs_; // Using an index, not an iterator, because after the erasure, the adjustment diff --git a/include/rex/ui/overlay/simple_settings_overlay.h b/include/rex/ui/overlay/simple_settings_overlay.h index 37c2df4..cdf2901 100644 --- a/include/rex/ui/overlay/simple_settings_overlay.h +++ b/include/rex/ui/overlay/simple_settings_overlay.h @@ -5,6 +5,7 @@ */ #pragma once +#include #include #include #include @@ -29,6 +30,15 @@ struct SimpleProfileState { int selected_index = 0; }; +// Raw pad snapshot for overlay navigation (host-side, already merged across +// pads). Poll callback runs on the UI thread every drawn frame. +struct SimpleSettingsGamepad { + bool connected = false; + uint16_t buttons = 0; // X_INPUT_GAMEPAD_* bits + int16_t thumb_lx = 0; + int16_t thumb_ly = 0; +}; + class SimpleSettingsDialog final : public ImGuiDialog { public: using LoadProfilesCallback = std::function; @@ -37,27 +47,42 @@ class SimpleSettingsDialog final : public ImGuiDialog { using CloseSettingsCallback = std::function; using CloseGameCallback = std::function; using RestartGameCallback = std::function; + using PollGamepadCallback = std::function; SimpleSettingsDialog(ImGuiDrawer* drawer, std::filesystem::path config_path, LoadProfilesCallback load_profiles, SaveProfileCallback save_profile, CloseSettingsCallback close_settings, CloseGameCallback close_game, - RestartGameCallback restart_game); + RestartGameCallback restart_game, + PollGamepadCallback poll_gamepad = nullptr); ~SimpleSettingsDialog(); void Show(); void Toggle(); void Hide(); + // One "back" step (Escape / pad B): text edit -> row focus -> category rail + // -> closed. The Escape keybind routes here so Escape backs out level by + // level instead of instantly closing. + void NavigateBack(); bool visible() const { return visible_; } protected: void OnDraw(ImGuiIO& io) override; private: + enum class FocusZone { kRail, kContent }; + + struct RowSpec; + struct NavIntents; + void LoadSettingsFromCvars(); bool HasSettingsChanges() const; void ReloadProfiles(); void SaveVideo(); void SaveProfile(); + void ApplyAndRestart(); + + void BuildRows(std::vector& rows, int category); + NavIntents GatherInput(ImGuiIO& io); std::filesystem::path config_path_; LoadProfilesCallback load_profiles_; @@ -65,8 +90,11 @@ class SimpleSettingsDialog final : public ImGuiDialog { CloseSettingsCallback close_settings_; CloseGameCallback close_game_; RestartGameCallback restart_game_; + PollGamepadCallback poll_gamepad_; SimpleProfileState profiles_; bool visible_ = false; + + // Staged setting values (committed by SaveVideo / SaveProfile). GraphicsDeviceList device_list_; int device_index_ = 0; int resolution_scale_index_ = 0; @@ -78,9 +106,27 @@ class SimpleSettingsDialog final : public ImGuiDialog { bool tearing_ = true; bool mnk_mode_ = false; bool mnk_capture_mouse_ = false; - int selected_tab_ = 0; bool profile_signed_in_ = true; char gamertag_buf_[32] = {}; + + // Navigation state. + FocusZone zone_ = FocusZone::kRail; + int category_ = 0; + int rail_sel_ = 0; // 0..category count-1 = categories; count = the Close Game item + int row_index_ = 0; + bool editing_text_ = false; + bool text_edit_focus_pending_ = false; + bool pad_active_ = false; // last nav input came from a pad -> pad legend + uint16_t prev_pad_buttons_ = 0; + int held_dir_x_ = 0; // current held direction (-1/0/1), for repeat + int held_dir_y_ = 0; + float repeat_timer_x_ = 0.0f; + float repeat_timer_y_ = 0.0f; + float highlight_anim_y_ = -1.0f; // smoothed selection highlight position + float rail_anim_y_ = -1.0f; + float content_scroll_ = 0.0f; + float mouse_x_ = -1.0f; // last mouse position, to detect real motion + float mouse_y_ = -1.0f; }; } // namespace rex::ui diff --git a/src/input/input_system.cpp b/src/input/input_system.cpp index 0c436c1..56181f1 100644 --- a/src/input/input_system.cpp +++ b/src/input/input_system.cpp @@ -10,8 +10,11 @@ */ #include +#include #include #include +#include +#include #include #include @@ -42,11 +45,57 @@ REXCVAR_DEFINE_STRING(input_backend, kDefaultInputBackend, "Input", "Input backe .allowed({"sdl", "xinput"}); REXCVAR_DEFINE_BOOL(guide_button, false, "Input", "Enable guide button pass-through"); +// Back+Start is deliberately not the default: Steam Input uses View+Menu +// (Back+Start) as its Guide-button chord on pads without a Guide button, so +// opening our settings would also pop the Steam overlay. +REXCVAR_DEFINE_STRING(menu_chord, "lb+rb+start", "Input", + "Controller chord that toggles the settings menu. Buttons joined by '+': " + "a, b, x, y, lb, rb, l3, r3, back, start, dpad_up, dpad_down, dpad_left, " + "dpad_right. Empty disables the chord."); REXCVAR_DEFINE_BOOL(hid_rumble_enabled, true, "Input", "Enable controller vibration"); REXCVAR_DEFINE_UINT32(hid_rumble_min_motor_speed, 0x1000, "Input", "Minimum XInput motor speed forwarded to host controllers") .range(0, 0xFFFF); namespace rex::input { +namespace { + +uint16_t ChordButtonFromToken(std::string_view token) { + if (token == "a") return X_INPUT_GAMEPAD_A; + if (token == "b") return X_INPUT_GAMEPAD_B; + if (token == "x") return X_INPUT_GAMEPAD_X; + if (token == "y") return X_INPUT_GAMEPAD_Y; + if (token == "lb") return X_INPUT_GAMEPAD_LEFT_SHOULDER; + if (token == "rb") return X_INPUT_GAMEPAD_RIGHT_SHOULDER; + if (token == "l3") return X_INPUT_GAMEPAD_LEFT_THUMB; + if (token == "r3") return X_INPUT_GAMEPAD_RIGHT_THUMB; + if (token == "back" || token == "select" || token == "view") return X_INPUT_GAMEPAD_BACK; + if (token == "start" || token == "menu") return X_INPUT_GAMEPAD_START; + if (token == "dpad_up") return X_INPUT_GAMEPAD_DPAD_UP; + if (token == "dpad_down") return X_INPUT_GAMEPAD_DPAD_DOWN; + if (token == "dpad_left") return X_INPUT_GAMEPAD_DPAD_LEFT; + if (token == "dpad_right") return X_INPUT_GAMEPAD_DPAD_RIGHT; + return 0; +} + +// "lb+rb+start" -> button mask; 0 when empty or no token parses (chord off). +uint16_t ChordMaskFromSpec(std::string_view spec) { + uint16_t mask = 0; + std::string token; + for (size_t i = 0; i <= spec.size(); ++i) { + char c = i < spec.size() ? spec[i] : '+'; + if (c == '+' || c == ',' || c == ' ') { + if (!token.empty()) { + mask |= ChordButtonFromToken(token); + token.clear(); + } + continue; + } + token.push_back(char(std::tolower(static_cast(c)))); + } + return mask; +} + +} // namespace InputSystem::InputSystem(rex::ui::Window* window) : window_(window) {} @@ -154,9 +203,10 @@ X_RESULT InputSystem::GetState(uint32_t user_index, X_INPUT_STATE* out_state) { return any_connected ? X_ERROR_EMPTY : X_ERROR_DEVICE_NOT_CONNECTED; } - constexpr uint16_t kMenuChordButtons = X_INPUT_GAMEPAD_BACK | X_INPUT_GAMEPAD_START; + const uint16_t menu_chord_buttons = ChordMaskFromSpec(REXCVAR_GET(menu_chord)); const bool menu_chord_down = - (static_cast(merged.gamepad.buttons) & kMenuChordButtons) == kMenuChordButtons; + menu_chord_buttons != 0 && + (static_cast(merged.gamepad.buttons) & menu_chord_buttons) == menu_chord_buttons; if (menu_chord_down && !menu_chord_down_ && menu_chord_callback_) { menu_chord_callback_(); } @@ -172,6 +222,38 @@ X_RESULT InputSystem::GetState(uint32_t user_index, X_INPUT_STATE* out_state) { return X_ERROR_SUCCESS; } +bool InputSystem::GetUiGamepadState(X_INPUT_GAMEPAD* out_gamepad) { + X_INPUT_GAMEPAD merged = {}; + bool any = false; + + auto merge_axis = [](int16_t a, int16_t b) -> int16_t { + return (std::abs(static_cast(a)) >= std::abs(static_cast(b))) ? a : b; + }; + + for (auto& driver : drivers_) { + for (uint32_t user_index = 0; user_index < 4; ++user_index) { + X_INPUT_STATE state = {}; + if (driver->GetStateUi(user_index, &state) != X_ERROR_SUCCESS) { + continue; + } + any = true; + merged.buttons = static_cast(merged.buttons) | + static_cast(state.gamepad.buttons); + merged.left_trigger = std::max(merged.left_trigger, state.gamepad.left_trigger); + merged.right_trigger = std::max(merged.right_trigger, state.gamepad.right_trigger); + merged.thumb_lx = merge_axis(merged.thumb_lx, state.gamepad.thumb_lx); + merged.thumb_ly = merge_axis(merged.thumb_ly, state.gamepad.thumb_ly); + merged.thumb_rx = merge_axis(merged.thumb_rx, state.gamepad.thumb_rx); + merged.thumb_ry = merge_axis(merged.thumb_ry, state.gamepad.thumb_ry); + } + } + + if (out_gamepad) { + *out_gamepad = merged; + } + return any; +} + X_RESULT InputSystem::SetState(uint32_t user_index, X_INPUT_VIBRATION* vibration) { SCOPE_profile_cpu_f("hid"); diff --git a/src/input/sdl/sdl_input_driver.cpp b/src/input/sdl/sdl_input_driver.cpp index 2868d42..1303219 100644 --- a/src/input/sdl/sdl_input_driver.cpp +++ b/src/input/sdl/sdl_input_driver.cpp @@ -209,6 +209,26 @@ X_RESULT SDLInputDriver::GetState(uint32_t user_index, X_INPUT_STATE* out_state) return X_ERROR_SUCCESS; } +X_RESULT SDLInputDriver::GetStateUi(uint32_t user_index, X_INPUT_STATE* out_state) { + assert(sdl_events_initialized_ && SDL_Gamepad_initialized_); + if (user_index >= HID_SDL_USER_COUNT) { + return X_ERROR_BAD_ARGUMENTS; + } + + QueueControllerUpdate(); + + auto guard = DrainAndLock(); + + auto controller = GetControllerState(user_index); + if (!controller) { + return X_ERROR_DEVICE_NOT_CONNECTED; + } + // Raw state, no is_active gating, and no packet/state_changed bookkeeping - + // that belongs to the guest-facing GetState path. + std::memcpy(out_state, &controller->state, sizeof(*out_state)); + return X_ERROR_SUCCESS; +} + X_RESULT SDLInputDriver::SetState(uint32_t user_index, X_INPUT_VIBRATION* vibration) { assert(sdl_events_initialized_ && SDL_Gamepad_initialized_); if (user_index >= HID_SDL_USER_COUNT) { diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index beb5909..d912912 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -2,6 +2,7 @@ # Core UI sources set(REXUI_CORE_SOURCES + fonts_inter.cpp graphics_device_list.cpp graphics_upload_buffer_pool.cpp graphics_util.cpp diff --git a/src/ui/fonts_inter.cpp b/src/ui/fonts_inter.cpp new file mode 100644 index 0000000..83741c3 --- /dev/null +++ b/src/ui/fonts_inter.cpp @@ -0,0 +1,381 @@ +/** + * @file ui/fonts_inter.cpp + * + * @brief Embedded Inter font (Latin subset) for styled overlays. + * + * Inter, by Rasmus Andersson and the Inter Project Authors + * (https://github.com/rsms/inter), subset to U+0020-00FF plus common + * typographic punctuation with fontTools and compressed with Dear ImGui's + * binary_to_compressed_c. Embedding one open font gives every platform an + * identical menu rendering. + * + * Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + * This Font Software is licensed under the SIL Open Font License, Version 1.1 + * (see the full license text below). + */ +/* + * Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + * + * This Font Software is licensed under the SIL Open Font License, Version 1.1. + * This license is copied below, and is also available with a FAQ at: + * http://scripts.sil.org/OFL + * + * ----------------------------------------------------------- + * SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + * ----------------------------------------------------------- + * + * PREAMBLE + * The goals of the Open Font License (OFL) are to stimulate worldwide + * development of collaborative font projects, to support the font creation + * efforts of academic and linguistic communities, and to provide a free and + * open framework in which fonts may be shared and improved in partnership + * with others. + * + * The OFL allows the licensed fonts to be used, studied, modified and + * redistributed freely as long as they are not sold by themselves. The + * fonts, including any derivative works, can be bundled, embedded, + * redistributed and/or sold with any software provided that any reserved + * names are not used by derivative works. The fonts and derivatives, + * however, cannot be released under any other type of license. The + * requirement for fonts to remain under this license does not apply + * to any document created using the fonts or their derivatives. + * + * DEFINITIONS + * "Font Software" refers to the set of files released by the Copyright + * Holder(s) under this license and clearly marked as such. This may + * include source files, build scripts and documentation. + * + * "Reserved Font Name" refers to any names specified as such after the + * copyright statement(s). + * + * "Original Version" refers to the collection of Font Software components as + * distributed by the Copyright Holder(s). + * + * "Modified Version" refers to any derivative made by adding to, deleting, + * or substituting -- in part or in whole -- any of the components of the + * Original Version, by changing formats or by porting the Font Software to a + * new environment. + * + * "Author" refers to any designer, engineer, programmer, technical + * writer or other person who contributed to the Font Software. + * + * PERMISSION AND CONDITIONS + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of the Font Software, to use, study, copy, merge, embed, modify, + * redistribute, and sell modified and unmodified copies of the Font + * Software, subject to the following conditions: + * + * 1) Neither the Font Software nor any of its individual components, + * in Original or Modified Versions, may be sold by itself. + * + * 2) Original or Modified Versions of the Font Software may be bundled, + * redistributed and/or sold with any software, provided that each copy + * contains the above copyright notice and this license. These can be + * included either as stand-alone text files, human-readable headers or + * in the appropriate machine-readable metadata fields within text or + * binary files as long as those fields can be easily viewed by the user. + * + * 3) No Modified Version of the Font Software may use the Reserved Font + * Name(s) unless explicit written permission is granted by the corresponding + * Copyright Holder. This restriction only applies to the primary font name as + * presented to the users. + * + * 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + * Software shall not be used to promote, endorse or advertise any + * Modified Version, except to acknowledge the contribution(s) of the + * Copyright Holder(s) and the Author(s) or with their explicit written + * permission. + * + * 5) The Font Software, modified or unmodified, in part or in whole, + * must be distributed entirely under this license, and must not be + * distributed under any other license. The requirement for fonts to + * remain under this license does not apply to any document created + * using the Font Software. + * + * TERMINATION + * This license becomes null and void if any of the above conditions are + * not met. + * + * DISCLAIMER + * THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + * OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + * COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + * DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + * OTHER DEALINGS IN THE FONT SOFTWARE. + */ + +#include + +namespace rex::ui { + +// File: 'InterMenu-Regular.ttf' (16744 bytes) +// Exported using binary_to_compressed_c.exe -base85 "InterMenu-Regular.ttf" kInterRegular +static const char kInterRegular_compressed_data_base85[17355+1] = + "7])#######xW+ND'/###[),##1xL$#Q6>##TZ;995BEL%AiES7Bf>11uRdL<(jlS;/qao7NqEn/3ExF>'aGI_u*m<-s?^01uZn42)WGo(D:iu5$sEn/aNV=B-b;1E#tEn/w.>>#Hb(*H" + "MY`=-%U^C-(BRm/#-0%J/q$J(tKPH2DQT`3I/d4^=B" + ".Pq(>9m###q$b30RGFiF]gExX]WGwKoBr--Q/5##]PUV$L&F9#BxC'SCbfC#(k^#$&uQX-D1NT/k5?a3wg-r#NI[MupNsS#6wVo861/h*$#H+2$if1BEGhf1;Zx6XEQR:vro4<%me''#" + "&s[>/V.k9#1]clNLwTkN/(G9#Ms`=-p(Au-X<&WR-uFT#hMAu-MVoCO37(%Ojh_U#;V_TM;8O&Fb3An'6]###8V5g)6QK.$SUZ>#WU>d3XpdMBKeWfU^Hi+i$M`RnI$V?#(tPMg28i$Y" + "bQ%YMq5U&$?5>##1I:;$3dP[.S'+&#Ea<71H&B.*ig'u$dqh[,0_Nj0jtK,3>U^:/n3H)4f$;Z>t,mm#TC<)C_uc4ju1v#r=,fhQ`oi0$EU:Z5'qqI;k5Ui*pN$Nkl]p)NaaOuIlru>Vatu>tqFeT;F#hNEN)=a" + "A&K=:4V=pjD*>>#teb4M;l@5/-A###ZAO(ju/@(sMbC3tn?SnLM**$#WMOV-@`6.1c7XI)p8KZ-epq0(Au4K1.[/G2:V]U#NOfkuEkL?#<:%@#'Wftucp@p$[:9l]P$Z4]b[)&4Aw82'" + "XNiWhQC)##DBb3=QD###2OB)&$h13$7b)`#$4=e#&nw8Y9eskuG0+_SR^e;%=_BK-q1Zd.,'?V#dFAu-gB`bR5L(:#is`=-8],[.sPtA#w-=J-7wUatu>r7[nQ7IKhuF4Anu%SCkaU&J[_,ve;M<0gr-*S=Q1?dnO(" + "D$nO(VBlD3RImM';7/+rvKX1K(7E+2$>e7@CarA#f3(@-fL_lL9NKYG`R.WtuxSw5COb7e,pQS@t@C5/8c###Vs7fhNl1;H.7WP&f%#^GTtpR*6iVrHflJ'J?K]CO,l+9#Pe%Y-k3Vw9" + "92+S*7/c%FA@/_JCVN1pF9e6EdV###PNv)4`FYI)*)TF4sCd26albV--+%7PT+T7aDk#)i1X/.QhA7m/cFqjn/<3Z$)A<>.fE9D3VvR$$sq4D#&j98.j6:hLs5]Y-O$2L,WE7L,*T.)*7TRwBL*x9.+M>c4dL4gLY@_i>oLK_IIMmvO(.'o-lLCl(B#=**=C@';5A2s@u%rPnUuk[=H=DYFgLqPpN2PYrt^x5&vL*cI.4sXZ*Q25E7['ac5/" + "AS9dD#.KfLuY88%KKEuuE>^Y#5CO1pdQtrmQiou,0X'k0aBf[#bYSF4Ke4`#EG'<6J#DnuSDcP#$DfR#N*>lumxj9i*jE$N,-/wKf?]V%bK)YYQ:+YYlqgL#UFoR#tj(t/#=(%O]/RUM" + "13DsHnI]>-$Ca8.L`1;H;u^9V`MVPgx+i$sB%%#:1r6(8UW:.1kTD3=XNu7'5pG3u.g,=`/H(JQ+dBJSwN3>$)?A%g5wR:JY@01oxkbB]$'D#k5B3rtx@Z&d&e9M(/LVnO=`2()xc<-Z*WB%" + "s%:8.8X-AgbmFM#n2W7eL_Q9i&s?aSl$Mq50LKbRv*I.Uh9i=uijQ/Tx&.4#C7_+v?9%#M)R6LM(QHH3SwC.3&>gG0i7WT/.W8f3*f:*>fCg?/?;Rv$#VB+*s81#$XeRs$S2osu4.Bi%" + "1(Ndd#@GRH]gf_S6NeQFUIO4aDUVMZC1[+10h.Fc4%Q[XAK.L3([HJVD*%8/#dhjPBf@X_$c8###LH7g)4o0N(DQ#N0F@x*iMD'F." + ">$:on@oOMgq'2_]_p*P(-r@$TBj7C&`?s;$&:oluwYXeA#PnD[%mHiu^*:=q?'=9u$gK(BDiH+$V):6TIxT:vvij322(D5/OR$##P+)#<4mlL-@^%#XwH>#4$l]#S6$,M0,i_%H;a`#v+Haj;]322oSt$6Wp%qusLZEjE+er8A:[E5pVA]tlA_=c" + "ETKi3E+'^79,?c2E&-5JrLKq9i*nCNMA4Z$d#jE-RreQ-;V7R&daJ[#3mbwjZYkv6m9,Z.$[(Uuh@F*$Ef2v6]B:uP0v3#(3vG2F6Bi#(^Q(#*Mc##achR#Bb%^.LH:;$b6ZX$" + "$>B+4sR>c4t29Z-:eS@#mGvA4nB7g)eT:-.vLc>#G4I3Kw#Omqc)&Z]+iCF3R;EU:7CuiVGHB$#YZ=c9m>s_x-?H_i$>.x_KOv_3XAINwDGUv?cc4j@h8.KUYQ'" + "(dTv-XiWI)l(2E4,>[8/_K#L1lUo'[keQ1YS0KGHO&e#7L;0D55SI?6^tDDZ-TLFi3XZ>CT[-d/,PlriM>kdZ7NTLNn>W9CB.lk#jrZqN'JoI+2GFxA$/&gDsAHT%JW4(?q'8thvn?>`" + "']?nK,GYE6#L(m&8bV&?7j'$9cE20@d05##Yo.T$$'F9#8Z8gMq<@<.Jl*F3RE:a#[7,i(TL.s$7'UB-.LdxL#_s`#QD7[9X+w(^_'*6LmmB0J-NDuDwGJEPlug9WVIk)-Qn*Tuj0^8:" + "_]Q4]ap:uPlsbkHQUOFm]*jDMg,+PAS$oM(ee%Q'Xv(D.N+i;HOJf&#e.RqL[w3@$1(3JM_(.&4x76lLGMD.Gw72cx'JRiaF3H2.6:UjU]$S^`6NU0vo7xqK&Jt6N:L=apm]?PSk90fq8WVu?IPW,ko*j435]f,vxO*&#G`AE%BtUdWqNK4MZV]kD8r4TK2:ps(WS47&Rk:B#v]dC$%g23U-O'Wm." + "T(kpu6_M=-O0+r.P`39#6m$T*%$*/Cu.OJiRbMq;n0CG)QjIfhYD[`*9AI6a4L]QS>?5>pQ[OL^x9M@0pt" + "G2$n>omL)_1#eX^qO?:#n1m3#Ia^Y$UG9I;qScH3)/'J3<)]L(Q@EeHN):A=pFK%Zo#+H^;gZX1&t7fhA@OfL&e)fqNugD=4klR*r'>S@4_bw98+uR*r0$PfK5(@-opWB-v6+r.X=B6v8x:b7.7ZruX_=oL-V*$#%*nO(0Ep;.Hv8+4t+-AXMPB8S" + "Im&E=u$Q[Ij%M)TF@Ur/u]cGS=[pj[,^_W=T.xT)NIO*b,'IV(##>>]crMSdu>)Yqr$" + "N[K,#]0s;-.=dD-Sm`m$BkA?7hGM#(A[7m8Ylq6TG#(*n]et]93'k.G&aFBQW#Vg1_>_R[],rUQf0h1T*2h](aG4c^#uO/PAp4fWq0VLS.G/IL(6_SNMDJUv-ei?<.]agdOIjA-[`[[b%o7jqL6fi?#51Fc%U;''%OVUKNOBJw@I_Xm#.bp%'JmtKG/*KLG" + "%>0N(o=Q29-?G)4:r,>9QRH?$@;=h(GJK=9cs,K)*d749Kr'^uGu=(-fVr&ZUTK5uC2jF&EgCfUPa.JhRhff(/M51MC@j1^2[aMG" + "fH%##;]k*vfkv&M+O2'#qVc8/XkC+*I5^+4uuZW.Ht'U%[`^F*&S'f)El:Z#BEvr,&o*=C.9nw_u#87`.^SI/almkEuKqVBhM&mF+m8gQdt+8C/N731I]5BRY0'Z0eE2W9AP7o/`nTYB" + "C4kL3xO@5J:`hi0^:)d3?[%CA;kZeMFEq;.1enI-Y(BgLk;hj%J-cIc_l:PHRs:D]*jf3Xd;x[TwZa8:p01+I)=u]=k/*$AuU*E[*C>?cYU_6&N]DS[s^(ZujV*71dU4U$Mg%-#;PUV$4>F$%lL8T%RXTx$CdR@YSGpeHLvm/[BPPNgjrhRutQ_YU" + "Onpmtxd=WC#)>>#jSov$Md''#Ru4[$'xBB#uO/PAR<^-?cx###D=/5=W(AZ5`Y0i)HIYQ';]d8/Ym9'^#)NKVam^Uc+jrO+N" + "a$O(4TPl++n*ArZcQ=G3QM:c+lb%duGPV$N0A?H#(:Bu?*I:9ie(hutxq;1B@60@f1Hc###23s79FbA,V;ZH*W`dC#/eL+*ijx`4JI^;.B(r_%Ku8E#c>6]g/deD,,hJ^P'p/M#GtC+/" + "uR#D40V's6[kOGVe?W#5J*3YGc4NSJW&v*5#%?]d8/DgN_=9[G]uGTeZ+FB,40G<$ou2%KIIYg8`EjgO@5b3A/Ce2dBQ4F]Y:_Yfwt5KQ$#" + "R(4GM[4OJp*+Nded##k#8nLhF-##O?uu#;T[I-EBWl%nS%&4ZH7g)p<6g)k&uIucv8EulS55NTjH^XI1ki&x6YY#wt(##rZQMgHs?D*jc;F3RfaiE<8:Z-g1n'=" + ".4:'YKspIVoRKljjSexa-Ue7TVNDju5xEpu,q@MbI>lOHvY-Po4@+HQI,2N0BEh'#$WH(#wK[,;bGw<&L%39/Xe_a4[SCa4H7#`4E`[T%vjWI)p*gucY)%eJaFmPk^e8,Kd(oi*8vWpD" + "tBe6g35*ntcBAI%F-r?nd-&rQMt&';xCsA,QEJk@EZ>uNS^b(xj+607MfcoKa'fdKAe&NRKA6U^0GGi:*kXLOqF#BTrO=F'T((FhvCI]`3M(sDQ/Hq5lsdc8UbZplr]&I,i0([HJVD('mA&c8+Y'" + "jHG$@N7LU#>Q5b@R-q(#8A+.#ZrM[$h#x%#jMOV-iD.&4GXR1MpP/N-(ogwLN[KP8-#9iu;-a>#6@Hw`$-NM99KUR#bYuf$2I/a#/vibNCaShuX#4gNF(0i#AnoE@" + ":csE@airrmLI$##I/TF4P4Y)4/h;E4As@feRZZ+*I>REFm`8%)W&8.]4?I4U$':/#YVkXrVIG2JLE$g..'5,.0]FJ',bFioOAS/" + "CXIhMU05##91ST$5x4<1EXI%#JTX&#xv9D3;l.T%&k*l(jFC8.ivq0(ct_3t&bpc))$UG)[P)%X$2RUWHlO@#xgkI#xHgiu$R,ju#QxLX&%c]+)J8;6u)pUmxx$YcafvpA^*2>PW2>8%" + "4K&W6L`Iu.#;4I&U_ll8$#H+2?/8(s6VF>#iI(##+=uQNh%$##vOpr6&aTW$ocCm9kV)B4FQhc)JcdN#c^X.5S6d^SWfe,;lY_ilwW&i%Fj@x^r;S&JUq?DJM'*)$=N;qiiVtuEIr^uU]2-1C2cT[J;qc>T7laiF;r2U:v9J-Gt@Wa[*O#(b[^sqFHg9SI" + "o41B4['F9#<(V$#mOwC#ZH/i)6o0N(:3(B#?7qxOWe]c^H&*A+H'kK$)rf]X$M2,2.kl#u[7JfL^x-tLxijmL1PM&Pid6lLi.2]$>76g)[H[F4R,xh%j9##>N^qd$Nc,4GZSC+><;^aP" + "^lF)$JRbS33_Nr;+w@WL3#*[]%UU0>e8dvS4=[,*YcqkuIMCOL]xc1lLDsM-bPq%/6JQ;#c5k,4.#.&49]3][a,ZJ(xjWU^,agW$^+x'A*(4H#6hTgc2Jj9+?vTobJsDOHuZ2$K4'UDQ" + "]Jo+@duCJ=WM;NV[==T#ko@AC/ilpLBC^FrH;VjkU=?]Npch)At9g]@W?TYSE.HL%GsNMg*8###mBM2%b..5SA(N]b[JJv,.t]St[7>##KO)sII9h#-2Mnx4.+7m8hkmtJweXI)GL?gL" + "dRn#%1M0+*QfEn:Lk*Q)LY(^S^v8OM0H?*IukaBf-]31=bP]EnQKF5>WZHx^$'eYTFqg&SM6CR`_2[qEgwD'sm(Ig1Zj'_3^#J+Z81Qa]mW:Er7,Q7TtLGg,)1Gbjfo799arn" + "F8DN0T_C+*VlAL(C(vM9-IMH*kSWe),17,;I9#B?CmKB@Ol9t(O8T+I15M<6YqtshId(JT-&Yd@H7EQ@E.M(Qi5*2>$x)1C77`QM^,qi'JJ`jkVIQ]Na[cmU6&*puB+Vc#0<#0?q.ql8MgUS;VWZXAO#^4WYQeiEK]>>-pktpf-;6qgRcHU37`#'K^W&h+Aqw/=" + ">w4O)seY]AoZc;A-/,s`xAE5DQqRbuWV[U;cP=(u1brE#WhjF/;_LHFOv:/:G$sh3'p>k<5<'##q@D0vvj'j$aC3+0E$(,)`?+5ND]F#L&<j-65gPN#'a>5Nru$9<9_-RNi-6G`bFro#urmnZai0Y^#&4PS>c41*.#%-kA9.^fse)=+?D#,;_<9_tDYC.&N)a.IC1I:O34E7TW@.PV$,bOW_/?" + "G@o,?IKd=KkP4TColhGZ7TvNDd['D3Sq5q`'=FoI[VXDEFqM?pmZT)Pgr=c4SY958wh_a4#IR+4V,jhLXU>hL1I_lSPAuY?6A@e-j^*XLYrdkM>XfN/VdVaNj%WKMcGNxJl]OpCVr8_/" + "U=aVs#7Fh'#E$(,)^cJ_4aSlY#rxw-)IVc(j+wl?[B*&vIW*E9M)#dO#PD6XJl,Jlu24F#:43guk*49WlxYPh$),##?X3B-xV3B-Z>G[08BF:.`8?[lngX(MT$Im8?p;KTfRZSJGK-f:r'(%`,vKF*J*eSn'VguLfR3+*:O-?RV:*IBC`SJ])*giww5;-[[I*4'K+P(OGg+4" + "N;-F%$IZ)4@Mte)BiaF3rF+gL#CY#%xS:Nq1Tha:ru7f&7iYx'j:2.KQMCwu%^/#@c/Y&oYpfmuLdh:Q*$'s.H?47/7gU?[cY0L>Y]N2W./J?4IG:;$>(F9#^3=&##$SX-*<4:.kJ(.$MOmV-JGVD3*p'.$84h0," + "`Ar+94cAWS=2RCk0dUFj+.CB#b4Na8Ub[['NeuF#`te=P$1CfCW4DN#dd-(&K9AwSJc(juhZQC#lmr+68:op$fLZ;#$WH(#qVc8/Zk_a4.W8f3DQaa42+9g1:>`:%#DXI)NW^:%H1;j9" + "P8;Z>&>ew;&n;R3a,vCP*Z+U^Je$[;MS:m4V6KFO.JVha>8P4HJ%kDID?AJcAj=mL-mKn_[^E=RArv0aNUU?;F`v'8d46#h[]M'fi=&l$86o%3Q%?QpkpY,0u+@Bd_G)4" + "`&BuGB]bRX8Il&#RbH##Abq>$ZM-@'A$wV%F03W%gqB4+N/>>#gKW-$Gm'hL66F;&/h'u$oroA?JXe%RhPsi>t-qf(-&hY?jWq<1AY%1$a'6]M2]),)cwAGVPIE)$KhQCu3L:.Q2:+>f" + ".//>-%r-A-F^&;/EW>SnBPkeMa:Q:v7$JY-V:Tk+ifHiT*c68%jJux=#)P:v6JO#$k_`=-%s@u-h%7fP$%QWM&729N5'jfu^6m3#QOc##>#=tcN#ua;^MNRYXtRCSn0d@AX#).,##3PUV$1&vp.(a2J<0Nl-$/?^e$ox>e6MNIv$=9Lw-o7cwLG'2'#CDfnLUed##A<(tMcSp$..bYcMTt[:.ORD20d-=vd#XIP/BJAeHb-vE7" + "Rw(4+*;bV6w>=,;K51[-,wM-Q_@^-#(=*P$iAP##JLH>#Y5G>#3x9J#Ngi7I@2cM#*:'quJWjjL=T#oLJW+vM&%wLMp5nF#G4DAf$nRxF&V,_JpSj>$Hw'w-vhqXMD/0P#mSdxLxeH29" + "2qx%Y2qx%YB8X-?,k+/1A#aMB*8###,2jc)4,swZc(s=5M)lS%DDw9c1w@Zum]PfL2^d)M:@2tMqCH>#;V`S%8v,3u<'%SugeFm#g,iDb.mX?#^b[o.%/5##&E0>$GAY]8==Y)42VSfL" + ":s*8$$dm,$oRiCX$8sSX(eGP2OOtE$/.L'#Srgo.VF3]-NH.a$UbYbu$$'MY$*OuGhBM;V$br@F[H*rLL5G9#pLaH$(F?I3MpKB#^-I>#@PC;$u+vjuDUEMB)%LB#BkP+V*iAlNwTVfu" + "FS3'I>LG�,*Qg@n]PJO[e'&pDW]+cDLg)6M$98IYA'H0r[PJLRGig.-pe4,$ge4>h0^tJbEm;/VnI>#1.Ga8Rh':/<_xASp%132(Ot5M1C7V#" + "3qK^#?1xfL-n@X-mg`$$ZHk*$O>WD##<>]=8gCW-(Tq7@xn(##&,###Z_G)4$8[7@r#D>#Qx$g1B`RDW'Do^f=ddC#pp^#$l>9$d2-,vi9`)g1$fBxO$s9M0f+5^GEH<;Q1[,T%rbr0ie[u`rul&#([?#]pK7n0t^au_HmxO6WI`KuB8-Z$h=xbuA3,Rf`Axb';G##T=j1CXmUA?e#K)EOx^-`<>V/7a=mB9/vG*" + "5RB_Q1VV=.rn0vgqYePVh48vgoP[PV_/lGdU=##g1o5%f]m;#^d0'#pPUV$" + "hcOjL;90C4_>%U9U*/s$jL-x6fM0k1#.,Q'm?`[,LVGKb_F=p@-dJ&$*o^:#07nM9kplS/u+aT%,p:0Ae#Z;T&]:H@l(Hm;BR.1<>RiV6&hP#Ae#mVT&Y(-@lF`k;DFMO;Dd>52HtA.$G<#qt`pC$9I-)9$MN;.FP^GOH*aGn*<6HKEBLGIIug(vo7?5" + "[0xlLw$wb#ktVuL8LXl%w4DT`,5vEK-%@RDPk6EI`d-EI=9:4FYP5kbCOX@Mea4+QjlUGZjc1,Z9++N0o%F9#RMOV-h@W$>:q`]-O3vv]g;pqGi0CO#k%('uMbjZNI_sMtb/kKPGR-ig" + "%CormKB@M9(NLV?Ysu8/g%*9/.IjJ:74ULj%rXI)ls'Y$*ktM0jCXI)#DSC4E@%lL*7Ss$R(R&Oqhv$Zl317+vS>dGjoZ]uo]C/5lh`P8)9fUWgp)-.[Z*3En1*B#>27VLAY)uhdfW;9" + "XDGS-B^IQ:,OshQfC;1=(`Mh3k#p@Z#6>S;-:WW856DC#bMifLxK6sZ>N<.bAl[&4RS+qZ2&+3%P0Bq#SLi&KA-r'#" + "rXj-$T6Y6#P$(,)c8G7(0HF:.Oi3'o^28H2LvHd)&4rJ#fQTZnT$7-)9,&t;q=-YXS79AUV414<4Z6Y*(QCcWOW'Bqm3uuo?#+#?uKb$sf0'#wdEB-)^Nj04Q5+#*Mc##l9k9#UShJ)lKJAclIYe-nk:_83(IDjq/t%cU+w1q(^KAcH?4F%;S@rmXKmcr$+OJ(CA^'/jwCP8Cmea$eA>HX'1ue)Dcu>#rZv)4" + "?X$$$(hro.x6:U)]GUv-FZFZ-)>*A'3,`gA]*(3MPMY'8jHACjlg^OLV3&th#]D4u^_.W;7V94;':ZCNM:XB,Lv]1>#" + "m7sW#=h@%#(Y2^MVRKvLnc2aMQbwAMoSMG)5M" + "KKfX-WmfXub2hpPvXKo//mK#$FN/=#x7l^%ur29/vBo8%;/'J3`cWF3q3Cx$:o.r2U9+D+kpL,:&OOI`$u^?-3;x$Z0qVFdG9hPe6ixTb$r`''#xbJfLdueqlXRT`N0$3$#" + "p'Ig=9pGg)R;hM:>JhB#m-iT9fsC99'Hpb%8[?IM`hv-N=o8gLsUXd$`tD)%_RXgLdB#W/8rNn:urNn:1cq,3g2xh2?pDq/=QlY#(h@%#+b*a%[#b1)LnE:.jGpq[[d?j]m;*p%$?7(s" + "3F`Y#L9(&XUtE/U=45W%9RvZIsYg;:se9q9c*vO:uGPEGdjEY9I5LTK7(u9)MB1l:tN@p^`Ns$#x:,dlbJ/J3'.Hv5:]3j4X,&G=_tNB@i3.PAITds.[']I$mgSJ=fKrv$" + "je4>%0=1sM^^Gs-mSL3MBM8W$f@bT97,5EGp6fERPT+FRMBfER4O2igob_##&,###$pID*@DtM'3Q5a$E:Ok+p=P.h-4h]OVkQg`bNZ:th#fAX)WxD-(#R+^*#-h7-#x?T2#KCY>#m+'$-$o)$qn$QGfp$,1@s$b?a)%vI+Q%pV2<%xDrG%%&CJ%UbsL%NXfX%:gT^%jL/a%C-Vc%&J-g%n5;k%1#Hr%`07w%3Xu'&`2=*&9om,&BnQO&b#X=&" + "7TPJ&7Z,R&DNwW&?5QZ&+-r_&>I-0'%>fp&amWt&t](G'ZxG;'1ZuF':N5N'AE1Z'Qcn)(-+:n'/2.D(LZa5(=Cm?(#HYf(BtdO(Ud$W(b^ti(FBSx(pMe%)`wM))Am:,)/hd0)wfc3)" + "WXO6)2?*9)_uP;)?0l>),/kA)[_2D)LSIH).7$K)&A`O)(KET)](k])KLte)PN&i)ZpZo)I]A.*QZF:*ag=^*cQ?O*^qGZ*#HC$+qW'j*Ooun*?Eq7+vt,(+@*j)+a5P++>:X.+K$nTM" + "4i_&P&;O=d;LFV%%osC=&qoCFeJB>#'G:;$V(1.*)Jus']G?8%Y9w0#r=5^RaLRm/'>7@uVU_f%aEi9.(/5##)=dZ$][:_$u__USBG8,r#%4?6aXLGi%w*.$n+Ll]7wW/$MJ,F.7_.F." + "D[Ml]'st-$<,-&M1-r)N+X_Q$tW9b$fc]r/fLHK%nB-0%,]P8.m9s)%8x,Z$hm7jL;:#gLnFDs-rqvM0*_CGrJ+9/$8mj;%T9Af.,T]]$I#T,MTqm##v(I0#9)qV-u.Ok+wpKfU&SP8." + "xgBF$[edumxQX]Y4uT^#2nE=$w;X7/)=2O#iT^sL*%Vp.qf`.qQ5q_&4oI_&[b%.$15eQMd-,I%#HvD-2CT;-7?&,OIJ/D$@_TJDw>nEIx+ffLZI+)#-xD<#P'>wLKT]F-#8T;-JSRFN" + "9_Nj04K]x$ODcB$r%5B$<@K.$;06MK$%@?c$Wtv,$t5OE$N@`K$BEsd$vq)H$hMsH$C*5o;s<,<3X5,E3@_#$" + "sl-qLIAjC$_n-qLJJ/`$J+%#Mb]d)MVpT(M7`:%MJ30?NBLI&MtZ2xL0Nn7$x?uu#/`Rh%w0AYG6Tr.C#ZVxXNXOfLahGP8Q(v(-vfNj0*,bxOpX(##-]Lv#5IC^#*w,tL0_CaNI'7f=/=9`a4GQ8/6c#<%V]l)M8xSfLN_;;$1jm&MmJG&#gI3#.SxefLc^/%#" + "V(4GMPRd>#Wn*K)rR>#O;#-M'@@mgD5h.U(KBVHq#m+MGqG`Nd/+M^" + "t6FcV[Ia(WYOA`WuvUuYrNW-Hah?YP-xmo%YJeKYhUH$#H@%%#LL7%#Xqn%#]'+&#b9F&#sj9'#wvK'#%-_'#1Q?(#5^Q(#9ea1#Clj1#8d46#&rm(#NVs)#Ui8*#b7p*#fC,+#vtu+#" + "$+2,#)=M,#:n@-#>$S-#D6o-#RaX.#Vmk.#xqs1#Cx&2#U$X9#uAU/#,4B2#.F^2#076&Mi&u#`+v/P]S5Ioe9>Oo[N-=`aV28GD19$AX[H](a4Z;YY]&6YctD.DN,]UV$,Puu#,i68%" + "6INP&@*gi'DBGJ(RA[`*R5%)*njvu,m3io.ltlr-Iai7[t#BJ1+W#,23>r%4P]s.CA%ox4DLKV6DF0;6N3)58[#]i9[m%29n:Q`DOcDN+1DEQX)>G" + ")/cxFWc1MTdU*GV`=IfU+lQS%Ylo#$1:NaJIk4Ra^f1+Od-?=$n+DTZn>H4Y3>5boL5.AqLHQhSMa+-&NSRxqLts%qLs`4rLZ(8qL(4oiLOa4rL%/KV-G)m<-" + "EgG<-&Rn*.@'BkL?;2/#PXkV.duJ*#;seQ-R`n[Z%Aie$I####S^`xO?_,j#M/>>#2[oi'lw0O+*tj-$vR'^#lLN;7pk/s7@cxA#ZfP9`Gtfx=pM?X(h/0eQsAE-ZCEG_&3P^QskmToI" + "w_0B#qMUlJdZYDO6ZOe$gF`+`7lPGalOQe$($6Yc@hE>dthQe$F.$JhvIFH+:&^1TY,Y9TT9wjTS%wqV*Ib_t*%>e2.-@Uel/t^v--%GJ=BtI_`*Pnl&######%sufXWV$##"; + + +// File: 'InterMenu-SemiBold.ttf' (16612 bytes) +// Exported using binary_to_compressed_c.exe -base85 "InterMenu-SemiBold.ttf" kInterSemiBold +static const char kInterSemiBold_compressed_data_base85[17270+1] = + "7])#######H_PAm'/###[),##1xL$#Q6>##TZ;995BEL%AiES7e?'o/uRdL<(jlS;J)m<-vrEn/3ExF>'aGI_u*m<-DA^01uZn42Ip*J));iu5NqEn/aNV=B-b;1E#tEn/K->>#Hb(*H" + "MY`=-Z>:@-N@Rm/#-0%JbR#Zf[MPH2DQT`3t-d$G6`'#WP?(#$+fh$ROc##)-bv5$LkA#ldK#$Ou;9/'ISs$gdfS%84XG)&V>Ku4,vR#k23m9.O0'-$#H+2$.d:Q>q/@DTCDNu1*P:vtu4<%(f''#" + "()o>/V.k9#A]clN_8NmN/(G9#us`=-#)Au-X<&WR-uFT##NAu-RuFDO.oO$Ojh_U#3V_TMv2C&Xd9An'6]###8V5g)6QK.$lcSd26xw_#>ewSuH[ELuZIu6upb,+WY6tP=1;+ru%@e.C" + "'F_fuCW/a#h@uu#e=(##6Ko9;uMW]+mXHq;^7K,3#DXI)dqh[,.UEj0=ALZ-J29f3n3H)4cS,^XB9PiVj'r5F(Y_,GcrsO]aXT;MZcH.h*$VePXAbLpf.]B[&O]6+/?FPV,CL#x_N1pELsrm@%$##quL^#nsFU.MDJ)*Ro29/*Trc)UQD.3crIv$A0cJ(g>YJ]%Fbbb,;[sZGk#@+hXOdohh3Lca83e)%EOb)xT`7q$O>V^XaTXMMlVsulp$cr_WsCoY*P:v" + "8'Pr?\?4__#S`($#*Mc##mi$4vO(x+MCXkPAq6@9&4V###,VfZpro2g(R:M;$WnlM($mC^#fgUZ#DW;876*I(a4^7V#hqoXjtu>#iN4R6I$^oe;G.q_RRg]N;G)M::-##%%-Z$JrbW%R/9,NGX@N#8NH&J`c%`tq^>38)(-Z$rYa>.fE9D3Ud%$$sq4D#uHNh#47:hLC;-?$lC0F#K8gEu/wJauEg7>e@jNi9vGI=S6=uQuQikm#" + "3`r48i5j>$>oWB-j*t^-m3_-68s1g$8kI?lAt?v$w^(EYnR^Y,'F9a#QIxNFi_vNFfCI8%w2gf1m`l]#6Un`%s,f]+=$`N(<[YQ'&Nx7RwL$W$R/s.3[jf8mOdYe2(]iV$Pce8u)YErV" + ";s1OPO^j@t'xJ<5=*3vA^'*CuV.ra21C,->b^jCuUq_d-mk`?Tx?o?TwrA+*gZv)4a(xY-O^T,*b&CP3)P)m/n0GT%YZdLgie269`QUm&Ph&lS%u.OJiA&8qr9>(##%xTMgDZ_c)YF]T%D39Z-UND.3>U^:/W([j#`?=mI5KIlu&Im2P" + "HT6r$:D.rPgjYguGDbdT,GvB8dxk+Dh.MDax00,)q?E^6wui,)u-Wv#/BxF4'Ras-VRWu7,mpkLdC<>#GtqFqegKZnhm[Mo:pNu7w%6wdO'HduCF&`#18h;fMD;>&Ob<9B(F5gLm,E$#" + "g'=m/mVWI)RiXV-PeP`12Csl&umOP8(e%`jbMRbe*.Mehs7>CuLDFAu.VMiKB]71#.dI)vj$3'MHU`'#m8pV.B(C+*L=sM0i+39/ND#G4:d^O9<9Qv$.v(C&[Lgm_*tlb#kextuU0@8%" + "75WTM-cmJY6R^7s-ws#O-u;P&CLFHQX.fcOb>B6N[8+?k:^A#Bu$C219'&XM3:ZLb.ZchF7rdO2,p+MCFxL-dlwsb.%H:;$Z.`q5=$(,)Z0fX-e1<_=S>)A4dQCJqG@[.(F>RfLXr?/v" + "9NfnLS9MhL:L-W.&#_'/jvPT%W`(Z#ho`Iq(7Pj^gOqQ#ad_IqVp^EV:j8f:T2/6Pd`6`3@A&.vB^*@99m>j#m1#qrQMc##%M?C#dFAu-p$clR6UCU#fq`=-I],[.%-nK#_%t[:(J+U&" + "EHkA#rrA+*[=?lLTd'^#Uk`W#rx'<-xZ5%7IoTS.EBVMg$EDKtbK0CcUe@+2FHf-6qMi*Ms:=JMj9MhLDjxF4mGpcNpeYV-.3#Vq0J#Dc;o/9ES*+);wXd9u4.)&TrR#YYbeu" + "Wb(u-x'?,*u(2'7m;IuF2Ij*$0nYGCi7TLkIDDCt/:c.<@II&[l&?AMG&](#8sMuL*uJ%#XwH>#i6cOFG$>`#NOR@#2:fu76o6E5`7q^uK.`Zu=&S%6,5uc4_x=u#B-/t>GxDsJ3#D?u" + "PsFY#fL0F6l:Tbu==mpLOH>,M54rP'Y_rM(nCQ29qgv5/7-)Gu8.5f*8vGA#^]HCuJ$p&$ki-b4qZ_2D-p39uE?*%;g+d1M;CD5/Wk$##O3$Pfj_2;HC?x:HB18>c6b/g)e[M)4" + "ZPf]%2Ah8.ZH7g)7Cn8%9%x[-$C<>GaKK3:a'Vi'bq.iTo2d0;EIHon/n@R3m7wY#7ZV=P+N9lL^e$##rEG'v#Ue<$[@O&#WPUV$>?9W$VM#G4=U^:/vBo8%-n-H35Hrc)Z_j=.3:,-3" + "rQ;Z>@4[s$iL0+*QDVeY1W?hR'f;2SijOCD>_+fXe@@w,IXRq:ixRAgIOTw#ZUO(q7e)6[70:2dh3MJ2Xf-j'C9rJ3i9r#Ykxu,k%rN" + "d[Z##/o$Y1`rZX1#q7fhAWB?.$2_)L4b]`XWZvfiliZuFs6-)(6dTLfx;#g);nqM`+Xg0_^CBqNbbX9+P:vx);'f2Y9_#5Vs)#" + "*Mc##O>3jL$(^fLX?KT$5,m=&],h,*[4qkL8EZ)4BPto7n/AZ5n2XjIU/cR3+71M=LuGkU)g_o._e-4GMB:k^#+oIt[kQ5mhO5;OcOVjafT.=GMf0AqL@mJ%#)X$p-" + "OK5ND<4158jTD^4;vS=rb=BaVw%W1u:SWBKTQ&lprFr.1jtFKL]M5S*ec$8[jxE8RVD1V=<=Vq9.]>#.S_P1>5=]#AI5+#)GY##'[fe?4NGJ$/6>##(C*R<.DH;$$9LCWQKM#vx6YY#4fd*N)id##=lG3#pKeum" + "7Ds]P=0mR*`/Tf::CvQWUuUeNd[Z##O+?X1Jds?9fO9r)'0oFiird?KudI+i'>)FIfD###'M4I)O?Z)4I5^+4*&USe-Z=K;>0^_/h.8]h^UR:vA*FGW)PYD=sqoW_$fDX_&[/e$2w^dM" + "2qeC#QC1gL6VB@#=I6_u>?[ouH$Q(BmS)rdYtGY,2J:]kE'xnNnX&G&%aO=u>f-W-pDH-dR[FM$87tReu" + "]v,>J($aNI&-rAJYxPi'd^gwA87*P8dN#5DZ]Nx=3G'?$0^8%872*XCkP-R/#<-3sI,R=u_Qx8.S6FIp]BJW-Lj_3Xc5X&%Rd''#%$###lWhsF4*22%N1]-?tiou,*6B39XJuD46Gl/M" + "qt@d)RQ&o8Yt+IWdL]LmKXjtTH%:3d,)G/lY?sJiihB9).Ms`iX-P@aq)o45igC'.#P*7q(kT*fs-UX&,Nb_d##)?x+%4pQ4o" + "A0Au-Add^RML(:#C5*B/Y^ahuY]1xLS#B%#X$(,)n`TO9=k><.d1NT/FfKP8sEYDO>AndF8vi$Bd;]R&:V+$IU4+PA)N(b9]tfs7S[2rZH5fZIO@6##5T]F-Xv=(.ETNn8c(OWSb>sI3" + "F(U?$J#Nl&el3$B-c29.*63%t(]i5/+=a]=.2w89MDXM_^&;?RZ[-##[?uu#DlX.#hE9D31-KE4+g^I*r%-98s/(<-6L&8SH[r3(6-8+QMkrEB2UfT$s10Iu=*,##jt7T$0R=c/MPUV$" + "us`a4]sFs-08m598_i/:&lgF*Vg'u$txe*b]qvH1LG8]Sq,UoGhYh9kQ7-1C4T9V:slA>54j0(X1F)u;['Y50_0]wSr]Vt12-#F5$jhn/_pcnV2(ea$s%9m$co80MnJO4:^v>K)[(9+*" + "bVeZ8^*,d38:JT%Km@d)uwL5jQ%h@NBB#,[XdhX_/5DNOm`V%@+Nx6Du+s6LLh<#]8Q;M-P5+)UFspjj&)*$J*-j%@qX?gtBX2eHQ<)fqAQeGDt:`$':%1N(fwe+%0ni8.]p*P(k;oO(" + "<.[=*p%SuJ;$C3n3N#mxt#6BJ4FV.)4Fv_)$mu9vP11;9j-*^b%duwsK*L" + "-JQH#+-[w@$.u8i5PJpt5^lu<]/[2aL.Yi;Kvg>$mqno;VrcG*[te8.(AH>#.QW#5%sKYG;+cY,^j1G+bj:vH/SM+V&5mUm%tJw5rhQDO0bH;-h97l3h#W.anlmUd/u_O3/H@(lo+BT/" + "aFcYP?$00)i+TC48la$[$7G#%?]d8/W_D>?hf`Zub6EUe-Gf-)8rkT#3]fG3]0Uu@h79j%r?qs9(?(cV<0In:`ollALkMbR3)P:v(&S#/ek?5/Iov&-h4]>OSP',Nded##sS+oLhF-##" + "LH:;$D/6c%VD###mS%&4ZH7g)p<6g)4NWDuebFIu5b$GX468dimptu%T7YY#e7r92vDSMgHs?D*WkQX-Y7-v&wHi8.$'lA#q@8g1OJUM#jv@`paHf-W-Yu1^:M5_.27NY&*jK2^sFMlhT@t" + "x_9*V?>?%B]hpV<3X7C7[=0A>VD" + "'JVgJ:f+dQxLBYuY-c>BtTx(Lw+k^Edwk_I@_;BfT?X/ga#Z?#;bxEblaJnG^t#.&pf@/>IKElA`-#eY_%0h'5^,C'>$P(?VRI@#f`_q:$=]Y#'0&##SUPMgc_18.$uF)4h?7f3XN,[T" + "w#a;%&'*@'knRs$Zn;FR-pl?QZMMugc;phmTu=RA*B$q(t@@VYxZIqCmF+psL:KOS@JAD'Gm+l:j.Af1oPXL33X]ZYwE>Sehh>7N?.>>#7IR-HJ#U-Hcf###PNkA#fv(`?#vH-+o$`.)3>U:%t$eqo73RvtuLn*Tu$'uIucpCQW$le`3$f0^#Jp,D-=5.J4fPZ;#(F9D3:LgU/]DsI32pxF4?;;.;bxlG*+]L_%L$:u$^>8+4[xA1(Vr=VZg,ldR" + "34MMk6ZPY,J1*`@pvCs$*uJlAcn%c5Y;>2r,LX7Z?@x]_:lDvgvSLNEFpjI])FSK+(c]_uE=f^$=OMo$kh1$#?PUV$epq0(#&_e$Zd*l(##A^$/xJh(e5`Iq%Jmf1B*TM'%vE.hDQcYU" + "/:%vUh1AVQP+u(E$[V%FM,^4Stq9V#A;P>#Btw[u4<*1#XsQfL*u&-#FB@s=t=ED#4*x9./`V^#GRZO&eHii9$#H+2[^etu15_W.PH:;$/U>`/sE9D3qTwF4*#-W-3Tj#noUJx$b$eX-" + "qFmV$P9p7EFKEfLjb%jlis?hQV[#&ORaDxFZc/@J'?(]gH%aFKjOk7P[7D^k9(#HEp?ZS[L4hM1#)Sv-59q+,OBUT&X0$#" + "H>wfD_ZxlK,%6Lsh[Ll%E;Rv$06SX-:gUD3,dQnapdl9M06JqLrd.k$&rID=E^hPM" + "`_J@>:e0^#Q.G(.RHCl:$H'EKB#uLUPT^UqiAs=i##7:12_WJFm0Mapkr.#+`>D62s^a7Uh&`'M0a:a%d#2>VXP$G-)HPewRQQ@S-Qxsm1>xWJ_KJm@&J?:J9DWA(4EWUXbEWYApUks8B+(Pax]rWr2H" + "#?ar%CgR*AA6<@fWpJLOWbu9LTs?ZB(jS>BWT^Y#U,lSo#Qqon]a&68?ZTj(2=Ss$5>Vm/m,vf(N.M;$xvwr'7@c.P@$*@#v#`ZBREAB4cb]?gg'?'WZhw'BTBj[X1I$7:kgYUKEOup)" + "#SOg>8@MNpL,Qmj`wdANlgaHX1p;5v$v?pa2XWOM;@4RoO;+D]CqDj5C81NVoQ:95@CF7%@/OvV7+YLK0(B7)t5DP4Mau=$;X:7t*'vjt(E#l*Oq(Va=v@UWm66fKs`5C[jd4:6YY#e8M.q>wUMg^^9p'9[ww#mr0s-" + ">;gF4_R(f)sWW&)x6t6Jk?EdD*%#DOZgxB43I/p]M;b(;w@l?0w@].5EsKOP6TZ]XpGM6=T83<>S_aOpKKduW57<$]4=&#O,>>#gCGv$s1Zs-k/<+=mn_a4#IR+4V,jhLT;Uv-A.&J." + "0Lqw?B*.g;xF@e-oF_XLe(9^#L(Ae-^$lXLsQ.##t`Vs#[Fh'#F$(,)^cJ_4aSlY#qo[h(0/`^#6XYS;Z3OI5ZAD4r2kRPA0C4I#.<_jurQj,SF:8tu.Z$U#=+,##VcEB-7@dZ%]+^h(" + ";4nY#P:,i(aBf[#5hKlDA?aU:prIA8e/J4ogRHC#E5:N#_=D7UQiOmuB])ruNc>_8#8>_8CB<_8TdrQNI16g)JH/i)0Kx+g984Q%+Tq[=?s%F.HPI4o[.LF.%-X-B01rZv)4/]NT/:5^F4i-^g1-d0i)$sse)JCn8%0x]fL/g=/*w<#]Qw=@MEsYYI>r.C>Lav$$AaSg;@#D_28LjxtE2kFq$ZbE=W4apS#+&X<5WX;+*&o[-5" + "#r<4NHn@)*%FMS.]CM)4bGfB.k@S_#^L,9/ocB.*CAfB.3s1u$l&q29:[iA.I(g;B_^&eQ9T3?JsbkjEN`cSd*pE<5Y_D+*$fR-5hwe'e#46RbA,^.qi%Hlaac#*#7xk+DvpsPSw6187" + "jS1bIApL^#%[EU.;DJ)*K0378BTlS/dSYW-D]&PDIaDm$?\?#<.epq0(k5=YJWh[^&/S^;-IOWu>1;M`W(e(U#Nf-B/mfSc6TNZT%&qhlNq[51bT2_1,Tl+f3f(siu')Q(W*5%2K85O8i" + "%HsL#ID>4BPUV$[%F9#^3=&##$SX-*<4:.kJ(.$MOmV-" + "JGVD3*p'.$84h0,LkjK7A]5#e+0^Pgrm&pf&fkA#$k?#5tOgZ3*haj7f,358lWhR#$,wK#VM#v5Ssaj0mi`ou2W^.LWwtu5*wglL?;I/L8,98.Vh$##B%NT/Y<#;/>;gF4av%G=6p7I*" + "9Ze)*ig'u$pH3Q/F0Cw.J>SHus2DOMB1sb4Tx0;Ms5IBCxL(@R^Nu@6Il^7NxU>=EC2%,HB?sib7#7])]lBnNh5R^C*Yl$Ragqx6g$>#" + "fXGV-*-Da*`S_$'xW'B#+NEV-EQi4%C%ns$-'FR/9)FV-rTV5]Y%.O(A:+SnrtH;s-[T2#w#[DlY#fi=&l$86o%" + "06)BPTh%j09:,@Bd_G)4]h9iBXZUBi8Il&#*>G##Abq>$ZM-@'A$wV%F03W%hwK4+#srf(U+qrQk-m2V=1J7&_ghV?HOI`QiV8/?u-qf(@W22NL3v&#Dd?g#PF^,0'GLuxun<-L,,jqpL?>)'#j$###r(mN#c'>uu7%Gc-)HMR*#4LR*?TSl]?mmr[$GPPT&ScPTjdLR*Jp;fL3-8`jpS,MT" + "SA(P&:3WD=IgMP^J7cB#lQkA#@r(Q#Nk$E.I,?PJ(OQ`k$]x.iQ8QEXQ(n7n^gOP&pn1j9arQC#hiV#AflV#An1[W8d5ns8m`$mLw[d##ovd8/wi`9MgR](W$" + "Ie).-5=c&#S+XCNjV;V$LQWjL5?cG-bIwA-ST0S/E]'B#s:+e#(>$d3<='6/jN>/$o[j)#Mrgo.GKkA#igDsu&FgM#^I$K#n*'Gi$mt10x6KfLhZ-+#2U['N(%wLMg^HF##xBW.#6Kiu" + "f91w-PlRfLbRpuckjd8/RU>eZY@E`am$&F7.$OZM2$&YM1q`=MrVo]%dl]]+:+9,M*8###(dQJ(KWPJ([sgUu'F:R#T.SoeJKiIhXus5vQ?vA#TE]w0XOww0F^9[0W.]p.R[l:-$ed19" + "GY2SnVq-##`Wa?#Q,5YuX5YY#@OlCsf'>AX]6Y'A*44]-jQj-$;-)6$$IA&$MBH:l_YSXk5mX+`(5&#Y#ZPP&YFBN(V$vM(H16g)BPXS7$SScM7KP0pshu5am:H_nc2_)]trZ" + "&w+:2*8QP/^%3D#=xG>#/+BxXpWfO#+,rP/uP>ku#*mf=1$^-W,R4;?pm.P#vXc=YLbOfLH6s7#Ei7L$QV*30Mqn%#Z$eX-$Tkp'xEI5/kH>K1>Cd4/]rgv@bhWv@OPRF%8LTh$3VFrBDJX#[r?ui1VY@0>l%2Kq]gM0O?_-6-8=E4ae_F*`^D.3u>K.*?]d8/ec6<.YqgeV" + "P._[9[SD[6rs_E#1an>Mi#gk2TX=o9:RlKd:@At/j9f(d9=Vnf7@b31t:$##$r71vlZOi$dv')0<%T*#oD,W.D@AJ.nm-H3$=Ap.VXmP/(=Yt(ELD8.>b/q`g=Q>#x1Qs-19.?\?&(gG36Obc%GlX=VYs[NI1s4)*$(3)*.7L^u" + "?&?cdT0BYSB;TF%D-wTGhEA*ZvuLG',thMDg=wi0Qc4fipL'^B*BnR=Y^$/04&#P,>>#tOQR(eV8f3vBo8%6'u,%]lWU%TtQHZE4w%IwRN[@EWs8fsrf4GB)7ML1iRY'wRbnD7:ehbWsW:FXjf$GS2SxDSFU'3#(QZ$@fZ(#Sfh-'#]NT/uZv)4dQ1P:3Xmek$oXI)IqiJ:.2?e4iA^.$2S>c4#8WT/]Cn8%',aY5ZA.xQHD6<^e#NAK/9p#2&$T/i&vjWP`#8?I.h>gFh;I9HW88d6t1jOhQM=:`.N.a?NZ=>eF>CYpg<9va-WqX/+%Bsc5=0w>M^elb,4NG88Jgob,4u)eNbfoB+$w-Qv$b<4gL" + "Cgx9.>A[&4U1nih1#43%LIiC3@e:NpqaXN3qDE4SMKGYB)3Jdob495pJPUIo6B5a9@%#+,##UY?,$=Lu=8$_l8/" + "4>;.;]nk%nTG6^=2f5g)R%*U%H@$GiEGCI#FGZ>8eKnP;$rd)>CoL&?I3x2WK>ftEncxuB2$KD70r,N189jE4./9q/E(Sj:ooU:D$N69%%OGR:'f2wgcr'/#L@E4:Zh5[-Wh-Yu[j2]b" + "MZMMgB$k;-9XOv-pqUu7uc6C#w,Ag_Ub6(un)s/QI).I-G[P+#xwI1$&Z7`=jX:K*oPft@S)W.3U2l$6.(]mNZ)5qC4P`2MjZgL;:^N.3$_DnN.w#8L(r4NTxjJ]=Ae#5fG(_m0JuBs-" + "c?rb*`HZcM]TS3%O5fxO^NE)$`+/R1x5?<8DUF:8SbtK;2P2#>mtO'SqMZ&@sk=:Ax@^c3C^()P7ILK2iWBQC4aRv8J:TQ&pN;ON:HV&#q;^;-0]Pn%#ufi':Ne+4FF+F3>HF:.omx9." + "8TvF#o^hWBLOl/_^=tf1^kNKH+`ac;AQ8$#sH_8iG1F%pkto@*c68%?0auuANf>-Y3Gv-Aj5TM:g>oL2A2r$24RA-)wEB-[''$.0NhhL[qQ##.#fP%Sbv;#/93jL" + "P1](#lPUV$5=t,5R*RYY2`aQs$#r(^=@WP$9.T&M:%3h'u$rK963WAF@?$a?Ab" + "n?.PAM0h;.N=Y$?vdV/=fKrv$MUvC%NoSn$E`:m8vk7oqZm'B#,71BTDCU^TUq(t8l#;99)GGVDg,1;D5=3_8eWPW8N?XmLJ=9wu`CE1%15>##c]LB#n.MJ$oOMSBFd#qLb2*(v4G,J$" + "&($50t&_lfqS,E$&?Ok+_ZR]OnYxi9U%[0)lu3+3)$R]OuJPfLrwSfL/6i:#]a($#=Nc##FBw<(B63x'IA$'#<>N)#m$),#GaX.#-C[8#0uL?#C%^E#)b7H#YGhJ#sn$S#@Z2W#tCcY#" + "M'=]#fa@d#:2Sl#/-Z7$WK'#$22W%$cn1($U:<-$xc`S$;i]A$K/_F$&l8I$iibM$/N9Y$Zb1_$QOqj$g0Bm$=a`o$mIL7%lp(,%A=CR%jn+>%i@jD%B'DG%9e/i%d%sW%SbLZ%/H'^%" + "^(N`%KE%d%KOah%5HTq%Jm&0U&I&Pi'j&j]`S&DC:V&rV[`&84Uj&4n&m&BBns&Wb0J'HaN9'qH/E'N0Eh'uxPV'L(4e'c,eo'MkK+(]4x1(=Cm?(" + ":4-G(>guJ(HELV(IXki(2E*t(sh`w(]g_$);YK')T:)*+8=)_5I@)RLmC)8sLG);t)L)K':R)>NLZ)n^fa)W(=e)fnYr)kWd,*HIV8*kj^F*#.4M*" + "2bXU*<*Ax*SGcf*NkBj*;-mv*c9Sx*-E:$+TxW&+7tV)+KV`S%)-?uH0tC+Wg*$oo4nw=-laVx5$&###j.'58Ooal#odPir2il##XR-#m##..NsNQ2tfQ3Q)7,MsnS+MBb4kQkL9#,=1m>#upEvmsSr`*tZn-$'r9#v:jnwugtG<-fgG<-LCpV-^)=F.apCa3]fKYmW%P`kn,V.$" + "$a/&O,GVPMiP:d-V@Vw0bq+;?:8l4ScUJG)D1SRM_JH)MPI#-Ma5$6#^0P6#6-'2#et=6#Yq&g1*2Wd$OQPJ$Bl*a$;a6QMZ:p0#Oiu##(5T*#E'J-#-wo=#iiI%#=.v+#Cag81Z=4&#" + "+3Z3#EC=&#@S[Q/$1FV?_ex@X&%$?$OIxnLYF,e#^v<$MlA^nL8fC%Mfk(jmI;&=_mfD<2I-UGfB.0,*Mp8CcA#x[k&#^tPMgD(k>-D$q-6VC9L54HKe$+jZw'`.;;$/UEF%>#O;#-M'@@mgD5h.U(KBVHq#m+MGqG`Nd/+M^t6FcV[Ia(WYOA`WuvUuYrNW-Hah?YP-xmo%YJeKYgBcf(^7)R$S-#D6o-#RaX.#Vmk.#xqs1#Cx&2#U$X9#uAU/#,4B2#.F^2#076&Mi&u#`+v/P]S5Ioe9>Oo[" + "N-=`aV28GD19$AX[H](a4Z;YY]&6YctD.DN,]UV$,Puu#,i68%6INP&@*gi'DBGJ(RA[`*R5%)*njvu,m3io.ltlr-Iai7[t#BJ1+W#,23>r%4P]s.CA%ox4DLKV6DF0;6N3)58[#]i9" + "[m%29n:Q`DOcDN+1DEQX)>G)/cxFWc1MTdU*GV`=IfU+lQS%Ylo#$1:N" + "moDJ1Z8-_J_e=X(Q$x?0]vPq2jmMk+B`M]=K7m-$eMeF@WC]w'5>B]OgZxcQ5mGoLJB]qLVLG&#@_nI-AoSN-xY5<-5Z5<-.F[:M" + "#Kf>-g_nI-QlUH-d?:@-W.%I-@CsM-K55dMJ9-&8V:o`=N^@K;=N4D[Vf]G)5sfDf+]>-)(2j1Kg6WAS$vG-)cEB-u8wx-(/'sLNZ5.#7Bg;-;mA,MeY`/#" + ">#AK8p&.VWh#" + ".#nw'nHu9))-lEI?=n(<1<7B#0KViBti_g@Qe$q-=`a>Ue]crbQe$,sx+B1?>#Ner*>/`:;$" + "'`vQNxLF&#i^v8/MXZY#P;PQ')>MMFDiu(/G#####m8AtBC(t/"; + + +const char* GetInterRegularCompressedBase85() { + return kInterRegular_compressed_data_base85; +} + +const char* GetInterSemiBoldCompressedBase85() { + return kInterSemiBold_compressed_data_base85; +} + +} // namespace rex::ui diff --git a/src/ui/imgui_drawer.cpp b/src/ui/imgui_drawer.cpp index 3771145..d86d060 100644 --- a/src/ui/imgui_drawer.cpp +++ b/src/ui/imgui_drawer.cpp @@ -11,13 +11,17 @@ #include #include +#include #include #include #include +#include +#include #include #include #include +#include #include #include #include @@ -132,15 +136,14 @@ void ImGuiDrawer::Initialize() { SetupFonts(); -#if defined(__APPLE__) - // Dialogs push runtime font sizes (PushFont(nullptr, 18..22)); the legacy + // Dialogs push runtime font sizes (PushFont(font, 18..40)); the legacy // prebaked atlas can only bitmap-scale its 10px bake to serve them, which - // blurs, doubly so under the Retina logical->physical magnification in - // RenderDrawLists. With this flag glyphs rasterize on demand at the drawn - // size, and io.DisplayFramebufferScale (set per-frame in Draw) additionally - // rasterizes them at display pixel density while layout stays logical. + // blurs. With this flag glyphs rasterize on demand at the drawn size, and + // io.DisplayFramebufferScale (set per-frame in Draw on macOS) additionally + // rasterizes them at Retina display pixel density while layout stays + // logical. The texture create/update requests are serviced by + // ProcessImGuiTextureRequests through the platform-agnostic ImmediateDrawer. io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; -#endif auto& style = ImGui::GetStyle(); style.ScrollbarRounding = 0; @@ -357,6 +360,98 @@ void ImGuiDrawer::SetupFonts() { if (font_setup_) { font_setup_(io.Fonts); } + + // UI fonts for styled overlays. Embedded Inter (OFL-licensed Latin subset, + // fonts_inter.cpp) so every platform renders identically; system fonts are + // only a fallback. With ImGuiBackendFlags_RendererHasTextures the size + // given here is only the default - glyphs rasterize at whatever size is + // pushed. MUST run after font_setup_: app callbacks (e.g. Skate3's + // OnConfigureFonts) may Clear() the atlas, which deletes every ImFont + // loaded before them - fonts cached here would dangle. + { + ImFontConfig config; + ui_font_ = io.Fonts->AddFontFromMemoryCompressedBase85TTF( + GetInterRegularCompressedBase85(), 18.0f, &config); + ui_font_semibold_ = io.Fonts->AddFontFromMemoryCompressedBase85TTF( + GetInterSemiBoldCompressedBase85(), 18.0f, &config); + } + auto add_first_available = [&io](const std::vector& paths) -> ImFont* { + for (const std::string& path : paths) { + if (!std::filesystem::exists(path)) { + continue; + } + ImFontConfig config; + config.FontNo = 0; + if (ImFont* font = io.Fonts->AddFontFromFileTTF(path.c_str(), 18.0f, &config)) { + return font; + } + } + return nullptr; + }; +#if REX_PLATFORM_WIN32 + // Helvetica family first (per-user font dir too - user-installed fonts land + // in %LOCALAPPDATA%, not C:\Windows\Fonts); Arial is a metric-compatible + // Helvetica clone every Windows ships, Segoe UI is the last resort. + std::string user_font_dir; + if (const char* local_appdata = std::getenv("LOCALAPPDATA")) { + user_font_dir = std::string(local_appdata) + "\\Microsoft\\Windows\\Fonts\\"; + } + auto win_font_paths = [&user_font_dir](std::initializer_list names) { + std::vector paths; + for (const char* name : names) { + if (!user_font_dir.empty()) { + paths.push_back(user_font_dir + name); + } + paths.push_back(std::string("C:\\Windows\\Fonts\\") + name); + } + return paths; + }; + if (!ui_font_) { + ui_font_ = add_first_available(win_font_paths( + {"HelveticaNowText-Regular.ttf", "HelveticaNowDisplay-Regular.ttf", + "HelveticaNow-Regular.ttf", "Helvetica.ttf", "HelveticaNeue.ttf", "arial.ttf", + "segoeui.ttf"})); + } + if (!ui_font_semibold_) { + ui_font_semibold_ = add_first_available(win_font_paths( + {"HelveticaNowText-Bold.ttf", "HelveticaNowDisplay-Bold.ttf", "HelveticaNow-Bold.ttf", + "Helvetica-Bold.ttf", "HelveticaNeue-Bold.ttf", "arialbd.ttf", "seguisb.ttf", + "segoeuib.ttf"})); + } +#elif defined(__APPLE__) + if (!ui_font_) { + ui_font_ = add_first_available({"/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/Supplemental/Arial.ttf"}); + } + if (!ui_font_semibold_) { + ui_font_semibold_ = + add_first_available({"/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/System/Library/Fonts/Helvetica.ttc"}); + } +#else + if (!ui_font_) { + ui_font_ = add_first_available( + {"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/noto/NotoSans-Regular.ttf"}); + } + if (!ui_font_semibold_) { + ui_font_semibold_ = add_first_available( + {"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", + "/usr/share/fonts/noto/NotoSans-Bold.ttf"}); + } +#endif + if (!ui_font_) { + REXLOG_WARN("No system UI font found; styled overlays use the default font"); + } + if (!ui_font_semibold_) { + ui_font_semibold_ = ui_font_; + } + REXLOG_INFO("imgui fonts: drawer={} atlas={} count={} ui={} uisb={}", (void*)this, + (void*)io.Fonts, io.Fonts->Fonts.Size, (void*)ui_font_, (void*)ui_font_semibold_); } void ImGuiDrawer::SetupFontTexture() { diff --git a/src/ui/overlay/simple_settings_overlay.cpp b/src/ui/overlay/simple_settings_overlay.cpp index 3f10756..01b93a6 100644 --- a/src/ui/overlay/simple_settings_overlay.cpp +++ b/src/ui/overlay/simple_settings_overlay.cpp @@ -2,17 +2,29 @@ * @file ui/overlay/simple_settings_overlay.cpp * * @brief Curated user-facing settings overlay. + * + * Fullscreen, console-style settings screen: category rail on the left, a + * column of setting rows with left/right value steppers in the middle, a + * description panel on the right and a button legend along the bottom. + * Fully navigable with a controller (dpad/left stick, A/B/X/Y, LB/RB), + * keyboard (arrows/Enter/Esc/Q/E/R/F) and mouse. */ #include #include #include +#include #include +#include +#include +#include +#include #include #include #include #include +#include #include namespace rex::ui { @@ -36,6 +48,52 @@ constexpr std::array kCoreSimpleSettingsCvars = { "mnk_mode", "mnk_capture_mouse"}; +// X_INPUT_GAMEPAD_* button bits, mirrored locally to keep the UI overlay +// decoupled from the kernel input headers. +constexpr uint16_t kPadDpadUp = 0x0001; +constexpr uint16_t kPadDpadDown = 0x0002; +constexpr uint16_t kPadDpadLeft = 0x0004; +constexpr uint16_t kPadDpadRight = 0x0008; +constexpr uint16_t kPadLShoulder = 0x0100; +constexpr uint16_t kPadRShoulder = 0x0200; +constexpr uint16_t kPadA = 0x1000; +constexpr uint16_t kPadB = 0x2000; +constexpr uint16_t kPadX = 0x4000; +constexpr uint16_t kPadY = 0x8000; + +// Categories shown in the left rail. +struct CategoryInfo { + const char* name; + const char* desc; +}; +constexpr std::array kCategories = {{ + {"Video", "Display, resolution and framerate settings."}, + {"Controls", "Mouse and keyboard input settings."}, + {"Profile", "Local player profile and sign-in."}, + {"System", "Apply or revert pending changes and close the settings."}, +}}; + +// Navigation repeat pacing (seconds). +constexpr float kRepeatDelay = 0.42f; +constexpr float kRepeatRate = 0.11f; + +// ---- Palette ------------------------------------------------------------- + +constexpr ImU32 kColSelFill = IM_COL32(243, 246, 247, 255); +constexpr ImU32 kColSelText = IM_COL32(13, 16, 18, 255); +constexpr ImU32 kColPanel = IM_COL32(15, 19, 22, 216); +constexpr ImU32 kColPanelHover = IM_COL32(30, 37, 41, 230); +constexpr ImU32 kColPanelBorder = IM_COL32(255, 255, 255, 14); +constexpr ImU32 kColText = IM_COL32(228, 233, 235, 255); +constexpr ImU32 kColTextDim = IM_COL32(148, 157, 161, 255); +constexpr ImU32 kColTextFaint = IM_COL32(105, 114, 118, 255); +constexpr ImU32 kColAccent = IM_COL32(62, 219, 190, 255); +constexpr ImU32 kColAccentDark = IM_COL32(9, 24, 21, 255); +constexpr ImU32 kColDanger = IM_COL32(233, 88, 76, 255); +constexpr ImU32 kColWarn = IM_COL32(245, 197, 87, 255); +constexpr ImU32 kColDescPanel = IM_COL32(11, 15, 17, 220); +constexpr ImU32 kColRule = IM_COL32(255, 255, 255, 26); + bool HasCvar(std::string_view name) { return rex::cvar::GetFlagInfo(name) != nullptr; } @@ -94,13 +152,12 @@ int DeviceIndexFromCvar(const GraphicsDeviceList& device_list) { return 0; } -int ResolutionIndexFromCvar() { - int32_t current = rex::cvar::Query("resolution_scale"); +int ResolutionIndexFromScale(int32_t scale) { int best = 0; int32_t best_delta = 1000; for (int i = 0; i < static_cast(kResolutionScales.size()); ++i) { - int32_t delta = kResolutionScales[i] > current ? kResolutionScales[i] - current - : current - kResolutionScales[i]; + int32_t delta = kResolutionScales[i] > scale ? kResolutionScales[i] - scale + : scale - kResolutionScales[i]; if (delta < best_delta) { best = i; best_delta = delta; @@ -109,6 +166,10 @@ int ResolutionIndexFromCvar() { return best; } +int ResolutionIndexFromCvar() { + return ResolutionIndexFromScale(rex::cvar::Query("resolution_scale")); +} + bool HasHostFrameCapCvars() { return HasCvar("d3d12_present_frame_limiter") && HasCvar("d3d12_present_frame_limiter_fps"); } @@ -128,6 +189,22 @@ bool HasFrameCapControl() { return UseGuestFrameCap() || HasHostFrameCapCvars(); } +int FrameCapIndexFromRate(double rate) { + if (rate < 1.0) { + return 0; + } + int best = 1; + double best_delta = 1000.0; + for (int i = 0; i < static_cast(kFrameCapRates.size()); ++i) { + double delta = std::abs(kFrameCapRates[i] - rate); + if (delta < best_delta) { + best = i + 1; + best_delta = delta; + } + } + return best; +} + int FrameCapIndexFromCvar() { double current = 0.0; if (UseGuestFrameCap()) { @@ -135,19 +212,7 @@ int FrameCapIndexFromCvar() { } else if (HasHostFrameCapCvars() && rex::cvar::Query("d3d12_present_frame_limiter")) { current = rex::cvar::Query("d3d12_present_frame_limiter_fps"); } - if (current < 1.0) { - return 0; - } - int best = 1; - double best_delta = 1000.0; - for (int i = 0; i < static_cast(kFrameCapRates.size()); ++i) { - double delta = std::abs(kFrameCapRates[i] - current); - if (delta < best_delta) { - best = i + 1; - best_delta = delta; - } - } - return best; + return FrameCapIndexFromRate(current); } bool HasFieldOfViewCvar() { @@ -161,6 +226,32 @@ float FieldOfViewFromCvar() { return float(std::clamp(rex::cvar::Query("skate3_field_of_view"), 40.0, 120.0)); } +// ---- CVar default parsing (Y / R "reset to default") ---------------------- + +std::optional CvarDefault(std::string_view name) { + const auto* info = rex::cvar::GetFlagInfo(name); + if (!info) { + return std::nullopt; + } + return info->default_value; +} + +bool CvarDefaultBool(std::string_view name, bool fallback) { + auto value = CvarDefault(name); + if (!value) { + return fallback; + } + return *value == "true" || *value == "1"; +} + +double CvarDefaultDouble(std::string_view name, double fallback) { + auto value = CvarDefault(name); + if (!value || value->empty()) { + return fallback; + } + return std::strtod(value->c_str(), nullptr); +} + void CopyToBuffer(char* buffer, size_t buffer_size, const std::string& value) { size_t count = std::min(value.size(), buffer_size - 1); std::copy_n(value.data(), count, buffer); @@ -181,6 +272,13 @@ bool TearingFromCvar() { return false; } +bool TearingDefault() { + if (HasCvar("d3d12_allow_variable_refresh_rate_and_tearing")) { + return CvarDefaultBool("d3d12_allow_variable_refresh_rate_and_tearing", true); + } + return CvarDefaultBool("vulkan_allow_present_mode_immediate", true); +} + void SetTearingCvars(bool value) { if (HasCvar("d3d12_allow_variable_refresh_rate_and_tearing")) { SetBoolCvar("d3d12_allow_variable_refresh_rate_and_tearing", value); @@ -196,29 +294,54 @@ void SetTearingCvars(bool value) { } } -void SectionTitle(const char* label) { - ImGui::PushFont(nullptr, 20.0f); - ImGui::TextUnformatted(label); - ImGui::PopFont(); - ImGui::Spacing(); +// ---- Small draw helpers ---------------------------------------------------- + +std::string TruncateToWidth(ImFont* font, float size, float max_width, std::string text) { + if (font->CalcTextSizeA(size, FLT_MAX, 0.0f, text.c_str()).x <= max_width) { + return text; + } + while (!text.empty() && + font->CalcTextSizeA(size, FLT_MAX, 0.0f, (text + "...").c_str()).x > max_width) { + text.pop_back(); + } + return text + "..."; } -void BeginFieldRow(const char* label) { - ImGui::PushID(label); - ImGui::TextUnformatted(label); - ImGui::SameLine(260.0f); - ImGui::SetNextItemWidth(-1.0f); +void AddTextCentered(ImDrawList* dl, ImFont* font, float size, ImVec2 center, ImU32 col, + const char* text) { + ImVec2 extent = font->CalcTextSizeA(size, FLT_MAX, 0.0f, text); + dl->AddText(font, size, ImVec2(center.x - extent.x * 0.5f, center.y - extent.y * 0.5f), col, + text); } -void EndFieldRow() { - ImGui::PopID(); - ImGui::Spacing(); +void AddTextVCentered(ImDrawList* dl, ImFont* font, float size, float x, float center_y, + ImU32 col, const char* text) { + ImVec2 extent = font->CalcTextSizeA(size, FLT_MAX, 0.0f, text); + dl->AddText(font, size, ImVec2(x, center_y - extent.y * 0.5f), col, text); } -bool PrimaryButton(const char* label) { - return ImGui::Button(label, ImVec2(170.0f, 42.0f)); +// Stepper chevron: a triangle in a circle. +void DrawChevron(ImDrawList* dl, ImVec2 center, float radius, bool points_left, ImU32 circle_col, + ImU32 tri_col, bool filled) { + if (filled) { + dl->AddCircleFilled(center, radius, circle_col, 24); + } else { + dl->AddCircle(center, radius, circle_col, 24, 1.5f); + } + float t = radius * 0.42f; + float dir = points_left ? -1.0f : 1.0f; + ImVec2 tip(center.x + dir * t, center.y); + ImVec2 a(center.x - dir * t * 0.6f, center.y - t); + ImVec2 b(center.x - dir * t * 0.6f, center.y + t); + dl->AddTriangleFilled(tip, a, b, tri_col); } +struct LegendGlyph { + const char* glyph; + const char* label; + bool circle; // pad face button -> circle, everything else -> key chip +}; + } // namespace void SaveSimpleSettingsConfig(const std::filesystem::path& config_path) { @@ -246,19 +369,64 @@ void EnsureSimpleSettingsConfig(const std::filesystem::path& config_path) { } } +// One setting row (or section header) in the content column. +struct SimpleSettingsDialog::RowSpec { + enum Kind { kHeader, kEnum, kSlider, kAction, kText }; + Kind kind = kEnum; + const char* label = nullptr; + const char* desc = nullptr; + std::string desc_extra; // dynamic addendum shown under desc + const char* value_note = nullptr; // short warning shown in the description + bool enabled = true; + bool danger = false; + // kEnum (options step left/right; either index or flag backs the value) + std::vector options; + int* index = nullptr; + bool* flag = nullptr; + std::function on_enum_change; + // kSlider + float* value = nullptr; + float min = 0.0f; + float max = 1.0f; + float step = 1.0f; + const char* fmt = "%.0f"; + std::function on_value_change; + // kAction + std::function action; + // kText + char* text_buf = nullptr; + size_t text_buf_size = 0; + // Reset-to-default handler (Y / R), unset when the row has no default. + std::function reset; +}; + +// Per-frame navigation intents merged from pad and keyboard. +struct SimpleSettingsDialog::NavIntents { + int move_x = 0; + int move_y = 0; + bool select = false; + bool back = false; + bool category_prev = false; + bool category_next = false; + bool reset_default = false; + bool apply_restart = false; +}; + SimpleSettingsDialog::SimpleSettingsDialog(ImGuiDrawer* drawer, std::filesystem::path config_path, LoadProfilesCallback load_profiles, SaveProfileCallback save_profile, CloseSettingsCallback close_settings, CloseGameCallback close_game, - RestartGameCallback restart_game) + RestartGameCallback restart_game, + PollGamepadCallback poll_gamepad) : ImGuiDialog(drawer), config_path_(std::move(config_path)), load_profiles_(std::move(load_profiles)), save_profile_(std::move(save_profile)), close_settings_(std::move(close_settings)), close_game_(std::move(close_game)), - restart_game_(std::move(restart_game)) { + restart_game_(std::move(restart_game)), + poll_gamepad_(std::move(poll_gamepad)) { ReloadProfiles(); LoadSettingsFromCvars(); SetDrawActive(false); @@ -271,6 +439,14 @@ void SimpleSettingsDialog::Show() { SetDrawActive(true); ReloadProfiles(); LoadSettingsFromCvars(); + zone_ = FocusZone::kRail; + rail_sel_ = category_; + row_index_ = 0; + content_scroll_ = 0.0f; + editing_text_ = false; + highlight_anim_y_ = -1.0f; + rail_anim_y_ = -1.0f; + prev_pad_buttons_ = 0xFFFF; // swallow buttons already held at open } void SimpleSettingsDialog::LoadSettingsFromCvars() { @@ -314,12 +490,28 @@ void SimpleSettingsDialog::Hide() { return; } visible_ = false; + editing_text_ = false; SetDrawActive(false); if (close_settings_) { close_settings_(); } } +void SimpleSettingsDialog::NavigateBack() { + if (!visible_) { + return; + } + if (editing_text_) { + editing_text_ = false; + return; + } + if (zone_ == FocusZone::kContent) { + zone_ = FocusZone::kRail; + return; + } + Hide(); +} + void SimpleSettingsDialog::ReloadProfiles() { profiles_ = load_profiles_ ? load_profiles_() : SimpleProfileState{}; if (profiles_.profiles.empty()) { @@ -388,248 +580,1160 @@ void SimpleSettingsDialog::SaveProfile() { ReloadProfiles(); } +void SimpleSettingsDialog::ApplyAndRestart() { + SaveVideo(); + if (restart_game_) { + restart_game_(); + } +} + +void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { + rows.clear(); + const bool pending = HasSettingsChanges(); + + auto header = [&rows](const char* label) { + RowSpec row; + row.kind = RowSpec::kHeader; + row.label = label; + rows.push_back(std::move(row)); + }; + + switch (category) { + case 0: { // Video + header("DISPLAY"); + if (!device_list_.device_names.empty() && HasCvar(device_list_.cvar_name)) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Graphics Device"; + row.desc = + "Select which GPU renders the game. Auto picks the highest-performance adapter."; + // Index-prefixed labels: virtual display drivers (Parsec etc.) make + // the same physical GPU enumerate several times, and the index is + // what the device cvar and the startup log speak in. + row.options.push_back("Auto (recommended)"); + for (size_t i = 0; i < device_list_.device_names.size(); ++i) { + row.options.push_back(std::to_string(i) + ": " + device_list_.device_names[i]); + } + row.index = &device_index_; + if (device_index_ > 0 && device_index_ <= int(device_list_.device_names.size())) { + row.desc_extra = "Current: " + row.options[device_index_]; + } + row.reset = [this] { device_index_ = 0; }; + rows.push_back(std::move(row)); + } + { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Resolution Scale"; + row.desc = + "Render resolution as a multiple of the game's native 720p. Higher values are " + "sharper but cost GPU time."; + for (const char* label : kResolutionLabels) { + row.options.push_back(label); + } + row.index = &resolution_scale_index_; + row.reset = [this] { + resolution_scale_index_ = ResolutionIndexFromScale( + int32_t(CvarDefaultDouble("resolution_scale", 3.0))); + }; + rows.push_back(std::move(row)); + } + if (HasFrameCapControl()) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Framerate Cap"; + row.desc = + "Limit how fast the game runs. A steady cap slightly below your display's " + "refresh rate gives the smoothest pacing on variable-refresh displays."; + for (const char* label : kFrameCapLabels) { + row.options.push_back(label); + } + row.index = &frame_cap_index_; + row.reset = [this] { + double rate = 0.0; + if (UseGuestFrameCap()) { + rate = CvarDefaultDouble("skate3_guest_fps_cap", 0.0); + } else if (CvarDefaultBool("d3d12_present_frame_limiter", false)) { + rate = CvarDefaultDouble("d3d12_present_frame_limiter_fps", 0.0); + } + frame_cap_index_ = FrameCapIndexFromRate(rate); + }; + rows.push_back(std::move(row)); + } + if (HasCvar("skate3_ultrawide")) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Aspect Ratio"; + row.desc = "21:9 widens the world rendering for ultrawide displays."; + row.value_note = "21:9 is experimental"; + for (const char* label : kAspectRatioLabels) { + row.options.push_back(label); + } + row.index = &aspect_ratio_index_; + row.reset = [this] { + aspect_ratio_index_ = CvarDefaultBool("skate3_ultrawide", false) ? 1 : 0; + }; + rows.push_back(std::move(row)); + } + { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Fullscreen"; + row.desc = "Borderless fullscreen presentation instead of a window."; + row.options = {"Off", "On"}; + row.flag = &fullscreen_; + row.reset = [this] { fullscreen_ = CvarDefaultBool("fullscreen", true); }; + rows.push_back(std::move(row)); + } + { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Vertical Synchronisation"; + row.desc = + "Waits for the display before presenting frames. Adds latency and can cause " + "judder with the frame pacing this port uses."; + row.value_note = "Not recommended"; + row.options = {"Off", "On"}; + row.flag = &vsync_; + row.reset = [this] { vsync_ = CvarDefaultBool("vsync", false); }; + rows.push_back(std::move(row)); + } + { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Variable Refresh / Tearing"; + row.desc = + "Allows uncapped presentation with variable-refresh displays (G-Sync / " + "FreeSync). Recommended on."; + row.options = {"Off", "On"}; + row.flag = &tearing_; + row.reset = [this] { tearing_ = TearingDefault(); }; + rows.push_back(std::move(row)); + } + if (HasFieldOfViewCvar()) { + header("GAMEPLAY"); + RowSpec row; + row.kind = RowSpec::kSlider; + row.label = "Field of View"; + row.desc = "Camera field of view in degrees. Applies immediately, no restart needed."; + row.value = &field_of_view_; + row.min = 40.0f; + row.max = 120.0f; + row.step = 2.0f; + row.fmt = "%.0f"; + row.on_value_change = [this] { + field_of_view_ = std::clamp(field_of_view_, 40.0f, 120.0f); + rex::cvar::SetFlagByName("skate3_field_of_view", std::to_string(field_of_view_)); + }; + row.reset = [this] { + field_of_view_ = float(CvarDefaultDouble("skate3_field_of_view", 60.0)); + rex::cvar::SetFlagByName("skate3_field_of_view", std::to_string(field_of_view_)); + SaveSimpleSettingsConfig(config_path_); + }; + rows.push_back(std::move(row)); + } + break; + } + case 1: { // Controls + header("MOUSE & KEYBOARD"); + { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Mouse & Keyboard Mode"; + row.desc = "Emulates a controller from mouse and keyboard input."; + row.options = {"Off", "On"}; + row.flag = &mnk_mode_; + row.reset = [this] { mnk_mode_ = CvarDefaultBool("mnk_mode", false); }; + rows.push_back(std::move(row)); + } + { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Capture Mouse"; + row.desc = + "Locks the mouse cursor to the game window while Mouse & Keyboard Mode is " + "active."; + row.options = {"Off", "On"}; + row.flag = &mnk_capture_mouse_; + row.reset = [this] { + mnk_capture_mouse_ = CvarDefaultBool("mnk_capture_mouse", true); + }; + rows.push_back(std::move(row)); + } + break; + } + case 2: { // Profile + header("LOCAL PROFILE"); + { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Profile"; + row.desc = "Select the local player profile used for saves."; + for (const auto& profile : profiles_.profiles) { + row.options.push_back(profile.gamertag); + } + row.index = &profiles_.selected_index; + row.on_enum_change = [this](int index) { + index = std::clamp(index, 0, static_cast(profiles_.profiles.size()) - 1); + CopyToBuffer(gamertag_buf_, sizeof(gamertag_buf_), + profiles_.profiles[index].gamertag); + profile_signed_in_ = profiles_.profiles[index].signed_in; + }; + rows.push_back(std::move(row)); + } + { + RowSpec row; + row.kind = RowSpec::kText; + row.label = "Gamertag"; + row.desc = "Display name for this profile. Select to edit with the keyboard."; + row.text_buf = gamertag_buf_; + row.text_buf_size = sizeof(gamertag_buf_); + rows.push_back(std::move(row)); + } + { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Local Sign-in"; + row.desc = "Whether this profile is signed in when the game boots."; + row.options = {"Signed out", "Signed in"}; + row.flag = &profile_signed_in_; + rows.push_back(std::move(row)); + } + { + RowSpec row; + row.kind = RowSpec::kAction; + row.label = "Save Profile"; + row.desc = "Save the profile changes above and apply them to the running game."; + row.action = [this] { SaveProfile(); }; + rows.push_back(std::move(row)); + } + break; + } + case 3: { // System + header("SESSION"); + { + RowSpec row; + row.kind = RowSpec::kAction; + row.label = "Apply & Restart"; + row.desc = pending + ? "Save the pending changes and restart the game to apply them." + : "No pending changes. Adjust a setting first, then apply it here."; + row.enabled = pending; + row.action = [this] { ApplyAndRestart(); }; + rows.push_back(std::move(row)); + } + { + RowSpec row; + row.kind = RowSpec::kAction; + row.label = "Revert Changes"; + row.desc = "Discard the pending changes and go back to the current settings."; + row.enabled = pending; + row.action = [this] { LoadSettingsFromCvars(); }; + rows.push_back(std::move(row)); + } + { + RowSpec row; + row.kind = RowSpec::kAction; + row.label = "Close Settings"; + row.desc = "Return to the game."; + row.action = [this] { Hide(); }; + rows.push_back(std::move(row)); + } + break; + } + default: + break; + } +} + +SimpleSettingsDialog::NavIntents SimpleSettingsDialog::GatherInput(ImGuiIO& io) { + NavIntents in; + + // ---- Gamepad ---- + SimpleSettingsGamepad pad; + if (poll_gamepad_) { + pad = poll_gamepad_(); + } + // prev_pad_buttons_ starts 0xFFFF on Show so buttons held while opening + // (e.g. the Back+Start chord) don't fire actions on the first frame. + const uint16_t pressed = pad.buttons & ~prev_pad_buttons_; + prev_pad_buttons_ = pad.buttons; + + int dir_x = 0; + int dir_y = 0; + if (pad.connected) { + if (pad.buttons & kPadDpadLeft) { + dir_x -= 1; + } + if (pad.buttons & kPadDpadRight) { + dir_x += 1; + } + if (pad.buttons & kPadDpadUp) { + dir_y -= 1; + } + if (pad.buttons & kPadDpadDown) { + dir_y += 1; + } + constexpr int16_t kDeadzone = 12000; + if (dir_x == 0) { + dir_x = pad.thumb_lx <= -kDeadzone ? -1 : (pad.thumb_lx >= kDeadzone ? 1 : 0); + } + if (dir_y == 0) { + // Stick up is positive Y in XInput; menu "up" is -1. + dir_y = pad.thumb_ly >= kDeadzone ? -1 : (pad.thumb_ly <= -kDeadzone ? 1 : 0); + } + } + + auto repeat_axis = [&io](int dir, int& held, float& timer) -> int { + if (dir == 0) { + held = 0; + timer = 0.0f; + return 0; + } + if (dir != held) { + held = dir; + timer = 0.0f; + return dir; // initial press + } + timer += io.DeltaTime; + if (timer >= kRepeatDelay) { + timer -= kRepeatRate; + return dir; + } + return 0; + }; + in.move_x = repeat_axis(dir_x, held_dir_x_, repeat_timer_x_); + in.move_y = repeat_axis(dir_y, held_dir_y_, repeat_timer_y_); + + if (pressed & kPadA) { + in.select = true; + } + if (pressed & kPadB) { + in.back = true; + } + if (pressed & kPadY) { + in.reset_default = true; + } + if (pressed & kPadX) { + in.apply_restart = true; + } + if (pressed & kPadLShoulder) { + in.category_prev = true; + } + if (pressed & kPadRShoulder) { + in.category_next = true; + } + if (pressed != 0 || dir_x != 0 || dir_y != 0) { + pad_active_ = true; + } + + // ---- Keyboard (Escape arrives via the keybind -> NavigateBack) ---- + if (!editing_text_) { + bool kb = false; + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow, true)) { + in.move_y -= 1; + kb = true; + } + if (ImGui::IsKeyPressed(ImGuiKey_DownArrow, true)) { + in.move_y += 1; + kb = true; + } + if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, true)) { + in.move_x -= 1; + kb = true; + } + if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, true)) { + in.move_x += 1; + kb = true; + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false) || + ImGui::IsKeyPressed(ImGuiKey_Space, false)) { + in.select = true; + kb = true; + } + if (ImGui::IsKeyPressed(ImGuiKey_Q, false)) { + in.category_prev = true; + kb = true; + } + if (ImGui::IsKeyPressed(ImGuiKey_E, false)) { + in.category_next = true; + kb = true; + } + if (ImGui::IsKeyPressed(ImGuiKey_R, false)) { + in.reset_default = true; + kb = true; + } + if (ImGui::IsKeyPressed(ImGuiKey_F, false)) { + in.apply_restart = true; + kb = true; + } + if (kb) { + pad_active_ = false; + } + } + + // Mouse motion switches the legend back to keyboard/mouse. + if (std::abs(io.MousePos.x - mouse_x_) > 2.0f || std::abs(io.MousePos.y - mouse_y_) > 2.0f) { + if (mouse_x_ >= 0.0f) { + pad_active_ = false; + } + } + + return in; +} + void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { if (!visible_) { return; } - const float window_width = - std::max(420.0f, std::min(820.0f, std::max(0.0f, io.DisplaySize.x - 48.0f))); - const float window_height = - std::max(560.0f, std::min(900.0f, std::max(0.0f, io.DisplaySize.y - 16.0f))); + ImFont* font = imgui_drawer()->ui_font() ? imgui_drawer()->ui_font() : ImGui::GetFont(); + ImFont* bold = imgui_drawer()->ui_font_semibold() ? imgui_drawer()->ui_font_semibold() : font; - ImGui::PushFont(nullptr, 18.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(24.0f, 22.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(14.0f, 9.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(12.0f, 12.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); - ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.045f, 0.055f, 0.065f, 0.96f)); - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.070f, 0.082f, 0.095f, 0.95f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.25f, 0.32f, 0.38f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_TitleBg, ImVec4(0.060f, 0.070f, 0.080f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.075f, 0.095f, 0.110f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.13f, 0.15f, 0.17f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, ImVec4(0.18f, 0.22f, 0.25f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ImVec4(0.22f, 0.28f, 0.31f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.38f, 0.42f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.15f, 0.49f, 0.54f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.10f, 0.32f, 0.36f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.12f, 0.38f, 0.42f, 0.72f)); - ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0.15f, 0.49f, 0.54f, 0.88f)); - ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0.17f, 0.56f, 0.62f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_CheckMark, ImVec4(0.52f, 0.88f, 0.82f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_SliderGrab, ImVec4(0.52f, 0.88f, 0.82f, 0.86f)); - ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, ImVec4(0.66f, 1.00f, 0.92f, 1.00f)); + // Uniform scale: design metrics are authored for 1080p. + const float s = std::clamp(io.DisplaySize.y / 1080.0f, 0.6f, 3.0f); - ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f), - ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - ImGui::SetNextWindowSize(ImVec2(window_width, window_height), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.96f); - if (!ImGui::Begin("Game Settings", nullptr, - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { - ImGui::End(); - ImGui::PopStyleColor(17); - ImGui::PopStyleVar(7); - ImGui::PopFont(); - return; + // ---- Input ---- + NavIntents in = GatherInput(io); + if (editing_text_) { + // The text field owns navigation; pad B cancels the edit. + if (in.back) { + editing_text_ = false; + } + in = NavIntents{}; } - ImGui::PushFont(nullptr, 22.0f); - ImGui::TextUnformatted("Settings"); - ImGui::PopFont(); - ImGui::Separator(); - ImGui::Spacing(); + const int category_count = static_cast(kCategories.size()); + // The rail holds the categories plus a pinned Close Game entry at the + // bottom (rail_sel_ == category_count). + const int quit_rail_index = category_count; + if (in.category_prev || in.category_next) { + category_ = (category_ + (in.category_next ? 1 : category_count - 1)) % category_count; + rail_sel_ = category_; + row_index_ = 0; + content_scroll_ = 0.0f; + highlight_anim_y_ = -1.0f; + } + if (zone_ == FocusZone::kRail && in.move_y != 0) { + rail_sel_ = std::clamp(rail_sel_ + in.move_y, 0, quit_rail_index); + if (rail_sel_ < category_count && rail_sel_ != category_) { + category_ = rail_sel_; + row_index_ = 0; + content_scroll_ = 0.0f; + highlight_anim_y_ = -1.0f; + } + } - if (ImGui::Button("Close Settings", ImVec2(170.0f, 40.0f))) { - Hide(); + std::vector rows; + BuildRows(rows, category_); + + // Selectable row indices (headers and disabled rows are skipped by nav). + std::vector selectable; + selectable.reserve(rows.size()); + for (int i = 0; i < static_cast(rows.size()); ++i) { + if (rows[i].kind != RowSpec::kHeader && rows[i].enabled) { + selectable.push_back(i); + } + } + auto clamp_focus_to_selectable = [&]() { + if (selectable.empty()) { + row_index_ = -1; + return; + } + int best = selectable[0]; + int best_delta = 1 << 30; + for (int idx : selectable) { + int delta = std::abs(idx - row_index_); + if (delta < best_delta) { + best = idx; + best_delta = delta; + } + } + row_index_ = best; + }; + clamp_focus_to_selectable(); + + auto enum_value = [](const RowSpec& row) -> int { + return row.flag ? (*row.flag ? 1 : 0) : (row.index ? *row.index : 0); + }; + auto set_enum_value = [](RowSpec& row, int value) { + if (row.flag) { + *row.flag = value != 0; + } else if (row.index) { + *row.index = value; + } + if (row.on_enum_change) { + row.on_enum_change(value); + } + }; + auto step_row = [&](RowSpec& row, int dir) { + if (row.kind == RowSpec::kEnum) { + int count = static_cast(row.options.size()); + if (count <= 0) { + return; + } + int value = ((enum_value(row) % count) + count) % count; + value = ((value + dir) % count + count) % count; + set_enum_value(row, value); + } else if (row.kind == RowSpec::kSlider && row.value) { + *row.value = std::clamp(*row.value + dir * row.step, row.min, row.max); + if (row.on_value_change) { + row.on_value_change(); + } + SaveSimpleSettingsConfig(config_path_); + } + }; + auto activate_row = [&](RowSpec& row) { + if (!row.enabled) { + return; + } + switch (row.kind) { + case RowSpec::kEnum: + step_row(row, 1); + break; + case RowSpec::kAction: + if (row.action) { + row.action(); + } + break; + case RowSpec::kText: + editing_text_ = true; + text_edit_focus_pending_ = true; + break; + default: + break; + } + }; + + // ---- Apply navigation intents ---- + bool row_activated_this_frame = false; + if (zone_ == FocusZone::kRail) { + if (rail_sel_ == quit_rail_index) { + if (in.select && close_game_) { + Hide(); + close_game_(); + } + } else if ((in.select || in.move_x > 0) && !selectable.empty()) { + zone_ = FocusZone::kContent; + row_index_ = selectable[0]; + } + if (in.back) { + Hide(); + } + } else { + if (in.move_y != 0 && !selectable.empty()) { + int pos = 0; + for (int i = 0; i < static_cast(selectable.size()); ++i) { + if (selectable[i] == row_index_) { + pos = i; + break; + } + } + pos = std::clamp(pos + in.move_y, 0, static_cast(selectable.size()) - 1); + row_index_ = selectable[pos]; + } + if (in.move_x != 0 && row_index_ >= 0) { + step_row(rows[row_index_], in.move_x); + } + if (in.select && row_index_ >= 0) { + activate_row(rows[row_index_]); + row_activated_this_frame = true; + } + if (in.back) { + zone_ = FocusZone::kRail; + } + } + if (in.reset_default && zone_ == FocusZone::kContent && row_index_ >= 0 && + rows[row_index_].reset) { + rows[row_index_].reset(); + } + if (in.apply_restart && HasSettingsChanges()) { + ApplyAndRestart(); + } + if (!visible_) { + return; // an action above closed the dialog + } + // Actions can invalidate row layout (pending-state rows); rebuild. + if (row_activated_this_frame || in.reset_default || in.apply_restart) { + BuildRows(rows, category_); + selectable.clear(); + for (int i = 0; i < static_cast(rows.size()); ++i) { + if (rows[i].kind != RowSpec::kHeader && rows[i].enabled) { + selectable.push_back(i); + } + } + clamp_focus_to_selectable(); + } + + const bool pending = HasSettingsChanges(); + + // ---- Layout ---- + const float margin_x = std::max(56.0f * s, io.DisplaySize.x * 0.05f); + const float title_y = io.DisplaySize.y * 0.085f; + const float title_size = 42.0f * s; + const float columns_y = title_y + 74.0f * s; + const float rail_w = 250.0f * s; + const float desc_w = 330.0f * s; + const float col_gap = 14.0f * s; + const float footer_h = 96.0f * s; + const float content_bottom = io.DisplaySize.y - footer_h - 18.0f * s; + float content_w = io.DisplaySize.x - 2.0f * margin_x - rail_w - desc_w - 2.0f * col_gap; + content_w = std::clamp(content_w, 300.0f * s, 800.0f * s); + // The column widths are capped, so on wide displays the block would hug the + // left margin - center the whole menu instead. + const float menu_total_w = rail_w + content_w + desc_w + 2.0f * col_gap; + const float rail_x = std::max(margin_x, (io.DisplaySize.x - menu_total_w) * 0.5f); + const float content_x = rail_x + rail_w + col_gap; + const float desc_x = content_x + content_w + col_gap; + const float menu_right = desc_x + desc_w; + + const float row_h = 52.0f * s; + const float header_h = 36.0f * s; + const float row_gap = 6.0f * s; + const float rail_item_h = 52.0f * s; + const float label_size = 19.0f * s; + const float value_size = 19.0f * s; + const float header_size = 14.5f * s; + const float desc_size = 16.5f * s; + + const ImVec2 mouse = io.MousePos; + const bool mouse_moved = + std::abs(mouse.x - mouse_x_) > 2.0f || std::abs(mouse.y - mouse_y_) > 2.0f; + mouse_x_ = mouse.x; + mouse_y_ = mouse.y; + const bool clicked = ImGui::IsMouseClicked(0); + auto mouse_in = [&mouse](float x0, float y0, float x1, float y1) { + return mouse.x >= x0 && mouse.x < x1 && mouse.y >= y0 && mouse.y < y1; + }; + + // ---- Window ---- + ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f)); + ImGui::SetNextWindowSize(io.DisplaySize); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + if (!ImGui::Begin("##skate3_settings_overlay", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus)) { ImGui::End(); - ImGui::PopStyleColor(17); - ImGui::PopStyleVar(7); - ImGui::PopFont(); + ImGui::PopStyleVar(2); return; } - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.48f, 0.12f, 0.10f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.62f, 0.16f, 0.13f, 1.00f)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.38f, 0.08f, 0.07f, 1.00f)); - if (ImGui::Button("Close Game", ImVec2(170.0f, 40.0f))) { - if (close_game_) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + + // ---- Backdrop scrim: even dim plus a stronger wash behind the columns ---- + dl->AddRectFilledMultiColor(ImVec2(0.0f, 0.0f), io.DisplaySize, IM_COL32(6, 9, 11, 150), + IM_COL32(6, 9, 11, 150), IM_COL32(3, 5, 7, 215), + IM_COL32(3, 5, 7, 215)); + { + // Extra wash behind the menu block, fading out symmetrically at both + // sides so the (centered) block reads against busy game scenes. + const float wash_pad = 110.0f * s; + const ImU32 wash = IM_COL32(4, 6, 8, 140); + const ImU32 wash_clear = IM_COL32(4, 6, 8, 0); + dl->AddRectFilledMultiColor(ImVec2(rail_x - wash_pad, 0.0f), + ImVec2(rail_x, io.DisplaySize.y), wash_clear, wash, wash, + wash_clear); + dl->AddRectFilled(ImVec2(rail_x, 0.0f), ImVec2(menu_right, io.DisplaySize.y), wash); + dl->AddRectFilledMultiColor(ImVec2(menu_right, 0.0f), + ImVec2(menu_right + wash_pad, io.DisplaySize.y), wash, + wash_clear, wash_clear, wash); + } + + // ---- Title ---- + dl->AddRectFilled(ImVec2(rail_x, title_y + 3.0f * s), + ImVec2(rail_x + 6.0f * s, title_y + title_size - 3.0f * s), kColAccent); + dl->AddText(bold, title_size, ImVec2(rail_x + 18.0f * s, title_y), kColText, "Settings"); + { + const char* cat_name = kCategories[category_].name; + char crumb[64]; + std::snprintf(crumb, sizeof(crumb), "/ %s", cat_name); + ImVec2 title_extent = bold->CalcTextSizeA(title_size, FLT_MAX, 0.0f, "Settings"); + dl->AddText(font, 20.0f * s, + ImVec2(rail_x + 18.0f * s + title_extent.x + 16.0f * s, + title_y + title_size - 26.0f * s), + kColTextDim, crumb); + } + if (pending) { + const char* chip_text = "RESTART REQUIRED TO APPLY"; + float chip_size = 14.0f * s; + ImVec2 extent = bold->CalcTextSizeA(chip_size, FLT_MAX, 0.0f, chip_text); + float chip_pad = 10.0f * s; + float chip_x1 = menu_right; + float chip_x0 = chip_x1 - extent.x - 2.0f * chip_pad; + float chip_y0 = title_y + title_size * 0.5f - extent.y * 0.5f - 6.0f * s; + float chip_y1 = chip_y0 + extent.y + 12.0f * s; + dl->AddRect(ImVec2(chip_x0, chip_y0), ImVec2(chip_x1, chip_y1), kColWarn, 0.0f, 0, + 1.5f); + dl->AddText(bold, chip_size, ImVec2(chip_x0 + chip_pad, chip_y0 + 6.0f * s), kColWarn, + chip_text); + } + dl->AddRectFilled(ImVec2(rail_x, columns_y - 12.0f * s), + ImVec2(menu_right, columns_y - 12.0f * s + 1.0f), kColRule); + + // ---- Left rail ---- + float rail_focus_target = -1.0f; + for (int i = 0; i < category_count; ++i) { + float y0 = columns_y + i * (rail_item_h + row_gap); + float y1 = y0 + rail_item_h; + bool is_current = category_ == i; + bool hovered = mouse_in(rail_x, y0, rail_x + rail_w, y1); + if (hovered && clicked) { + category_ = i; + rail_sel_ = i; + zone_ = FocusZone::kRail; + row_index_ = 0; + content_scroll_ = 0.0f; + is_current = true; + } + if (is_current && zone_ == FocusZone::kRail && rail_sel_ == i) { + rail_focus_target = y0; + } + ImU32 bg = is_current ? kColPanelHover : (hovered ? kColPanelHover : kColPanel); + dl->AddRectFilled(ImVec2(rail_x, y0), ImVec2(rail_x + rail_w, y1), bg, 0.0f); + dl->AddRect(ImVec2(rail_x, y0), ImVec2(rail_x + rail_w, y1), kColPanelBorder, 0.0f); + // Static accent bar only while focus lives in the content column; while + // navigating the rail the bar rides the sliding highlight below so both + // move at the same rate. + if (is_current && zone_ == FocusZone::kContent) { + dl->AddRectFilled(ImVec2(rail_x, y0), ImVec2(rail_x + 4.0f * s, y1), kColAccent, + 0.0f, ImDrawFlags_RoundCornersLeft); + } + } + // Focused rail item: sliding white fill (with the accent bar attached to + // its left edge) drawn over the panels, then labels. + if (rail_focus_target >= 0.0f) { + if (rail_anim_y_ < 0.0f || std::abs(rail_anim_y_ - rail_focus_target) > 160.0f * s) { + rail_anim_y_ = rail_focus_target; + } + rail_anim_y_ += (rail_focus_target - rail_anim_y_) * std::min(1.0f, io.DeltaTime * 22.0f); + dl->AddRectFilled(ImVec2(rail_x, rail_anim_y_), + ImVec2(rail_x + rail_w, rail_anim_y_ + rail_item_h), kColSelFill, + 0.0f); + dl->AddRectFilled(ImVec2(rail_x, rail_anim_y_), + ImVec2(rail_x + 4.0f * s, rail_anim_y_ + rail_item_h), kColAccent, + 0.0f, ImDrawFlags_RoundCornersLeft); + } else { + rail_anim_y_ = -1.0f; + } + for (int i = 0; i < category_count; ++i) { + float y0 = columns_y + i * (rail_item_h + row_gap); + bool is_current = category_ == i; + bool focused = is_current && zone_ == FocusZone::kRail && rail_sel_ == i; + ImU32 text_col = focused ? kColSelText : (is_current ? kColText : kColTextDim); + AddTextVCentered(dl, focused || is_current ? bold : font, label_size, rail_x + 20.0f * s, + y0 + rail_item_h * 0.5f, text_col, kCategories[i].name); + if (is_current && zone_ == FocusZone::kContent) { + // Arrow hinting that focus lives in the content column. + float ax = rail_x + rail_w - 18.0f * s; + float ay = y0 + rail_item_h * 0.5f; + dl->AddTriangleFilled(ImVec2(ax, ay - 6.0f * s), ImVec2(ax, ay + 6.0f * s), + ImVec2(ax + 8.0f * s, ay), kColAccent); + } + } + + // Close Game entry: below the categories, set apart by an extra gap. + { + float y0 = columns_y + category_count * (rail_item_h + row_gap) + 26.0f * s; + float y1 = y0 + rail_item_h; + bool focused = zone_ == FocusZone::kRail && rail_sel_ == quit_rail_index; + bool hovered = mouse_in(rail_x, y0, rail_x + rail_w, y1); + if (hovered && clicked && close_game_) { Hide(); close_game_(); } - ImGui::PopStyleColor(3); - ImGui::End(); - ImGui::PopStyleColor(17); - ImGui::PopStyleVar(7); - ImGui::PopFont(); - return; + ImU32 bg = focused ? kColDanger : (hovered ? kColPanelHover : kColPanel); + dl->AddRectFilled(ImVec2(rail_x, y0), ImVec2(rail_x + rail_w, y1), bg, 0.0f); + dl->AddRect(ImVec2(rail_x, y0), ImVec2(rail_x + rail_w, y1), + focused ? kColDanger : kColPanelBorder, 0.0f); + if (!focused) { + dl->AddRectFilled(ImVec2(rail_x, y0), ImVec2(rail_x + 4.0f * s, y1), kColDanger, + 0.0f, ImDrawFlags_RoundCornersLeft); + } + AddTextVCentered(dl, bold, label_size, rail_x + 20.0f * s, (y0 + y1) * 0.5f, + focused ? IM_COL32(255, 250, 249, 255) : kColDanger, "Close Game"); } - ImGui::PopStyleColor(3); - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - ImGui::BeginChild("##simple_settings_nav", ImVec2(180.0f, 0.0f), true); - const char* tabs[] = {"Video", "Controls", "Profile"}; - for (int i = 0; i < 3; ++i) { - if (ImGui::Selectable(tabs[i], selected_tab_ == i, 0, ImVec2(0.0f, 42.0f))) { - selected_tab_ = i; + // ---- Content column ---- + // Row geometry (in unscrolled space). + std::vector row_y(rows.size()); + std::vector row_hgt(rows.size()); + { + float y = 0.0f; + for (int i = 0; i < static_cast(rows.size()); ++i) { + row_y[i] = y; + row_hgt[i] = rows[i].kind == RowSpec::kHeader ? header_h : row_h; + y += row_hgt[i] + row_gap; } } - ImGui::EndChild(); + const float content_view_h = content_bottom - columns_y; + const float rows_total_h = rows.empty() ? 0.0f : row_y.back() + row_hgt.back(); + const float max_scroll = std::max(0.0f, rows_total_h - content_view_h); - ImGui::SameLine(); - - ImGui::BeginChild("##simple_settings_body", ImVec2(0.0f, 0.0f), true, - ImGuiWindowFlags_NoScrollbar); - const bool has_settings_changes = HasSettingsChanges(); - if (selected_tab_ == 0) { - SectionTitle("Video"); - - if (!device_list_.device_names.empty() && HasCvar(device_list_.cvar_name)) { - BeginFieldRow("Graphics Device"); - // Index-prefixed labels: virtual display drivers (Parsec etc.) make the - // same physical GPU enumerate several times, and the index is what the - // device cvar and the startup log speak in. - std::vector device_label_storage; - device_label_storage.reserve(device_list_.device_names.size()); - for (size_t i = 0; i < device_list_.device_names.size(); ++i) { - device_label_storage.push_back(std::to_string(i) + ": " + device_list_.device_names[i]); - } - std::vector device_labels; - device_labels.reserve(device_label_storage.size() + 1); - device_labels.push_back("Auto (recommended)"); - for (const auto& label : device_label_storage) { - device_labels.push_back(label.c_str()); - } - ImGui::Combo("##graphics_device", &device_index_, device_labels.data(), - static_cast(device_labels.size())); - EndFieldRow(); + // Wheel scroll while over the content column. + if (io.MouseWheel != 0.0f && + mouse_in(content_x, columns_y, content_x + content_w, content_bottom)) { + content_scroll_ -= io.MouseWheel * 60.0f * s; + } + // Keep the focused row in view when navigating. + if (zone_ == FocusZone::kContent && row_index_ >= 0 && (in.move_y != 0 || in.select)) { + float y0 = row_y[row_index_]; + float y1 = y0 + row_hgt[row_index_]; + if (y0 - content_scroll_ < 0.0f) { + content_scroll_ = y0; + } else if (y1 - content_scroll_ > content_view_h) { + content_scroll_ = y1 - content_view_h; } + } + content_scroll_ = std::clamp(content_scroll_, 0.0f, max_scroll); - BeginFieldRow("Resolution Scale"); - ImGui::Combo("##resolution_scale", &resolution_scale_index_, kResolutionLabels.data(), - static_cast(kResolutionLabels.size())); - EndFieldRow(); + dl->PushClipRect(ImVec2(content_x, columns_y - 2.0f), ImVec2(content_x + content_w + 1.0f, + content_bottom), + true); - if (HasFrameCapControl()) { - BeginFieldRow("Framerate Cap"); - ImGui::Combo("##frame_cap", &frame_cap_index_, kFrameCapLabels.data(), - static_cast(kFrameCapLabels.size())); - EndFieldRow(); - } - - if (HasCvar("skate3_ultrawide")) { - BeginFieldRow("Aspect Ratio"); - ImGui::Combo("##aspect_ratio", &aspect_ratio_index_, kAspectRatioLabels.data(), - static_cast(kAspectRatioLabels.size())); - EndFieldRow(); - } - - if (HasFieldOfViewCvar()) { - BeginFieldRow("Field of View"); - if (ImGui::SliderFloat("##field_of_view", &field_of_view_, 40.0f, 120.0f, "%.0f")) { - field_of_view_ = std::clamp(field_of_view_, 40.0f, 120.0f); - rex::cvar::SetFlagByName("skate3_field_of_view", std::to_string(field_of_view_)); - } - if (ImGui::IsItemDeactivatedAfterEdit()) { - SaveSimpleSettingsConfig(config_path_); - } - EndFieldRow(); - } - - ImGui::Separator(); - ImGui::Spacing(); - ImGui::Checkbox("Fullscreen", &fullscreen_); - ImGui::Checkbox("VSync", &vsync_); - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.95f, 0.28f, 0.24f, 1.0f), "not recommended"); - ImGui::Checkbox("Variable refresh / tearing", &tearing_); - - ImGui::Dummy(ImVec2(0.0f, 8.0f)); - if (has_settings_changes) { - ImGui::TextColored(ImVec4(0.95f, 0.76f, 0.30f, 1.0f), - "Restart required to apply these changes."); - if (PrimaryButton("Apply & Restart")) { - SaveVideo(); - if (restart_game_) { - restart_game_(); - } - } - ImGui::SameLine(); - if (ImGui::Button("Cancel Changes", ImVec2(170.0f, 42.0f))) { - LoadSettingsFromCvars(); - } - } else { - ImGui::TextDisabled("No pending changes"); - } - } else if (selected_tab_ == 1) { - SectionTitle("Controls"); - - ImGui::Checkbox("Mouse and keyboard mode", &mnk_mode_); - ImGui::Checkbox("Capture mouse", &mnk_capture_mouse_); - - ImGui::Dummy(ImVec2(0.0f, 8.0f)); - if (has_settings_changes) { - ImGui::TextColored(ImVec4(0.95f, 0.76f, 0.30f, 1.0f), - "Restart required to apply these changes."); - if (PrimaryButton("Apply & Restart")) { - SaveVideo(); - if (restart_game_) { - restart_game_(); - } - } - ImGui::SameLine(); - if (ImGui::Button("Cancel Changes", ImVec2(170.0f, 42.0f))) { - LoadSettingsFromCvars(); - } - } else { - ImGui::TextDisabled("No pending changes"); + // Sliding selection highlight (white fill under the focused row). + const bool content_focus = zone_ == FocusZone::kContent && row_index_ >= 0; + if (content_focus) { + float target = row_y[row_index_]; + if (highlight_anim_y_ < 0.0f || std::abs(highlight_anim_y_ - target) > 160.0f * s) { + highlight_anim_y_ = target; } + highlight_anim_y_ += (target - highlight_anim_y_) * std::min(1.0f, io.DeltaTime * 22.0f); } else { - SectionTitle("Profile"); + highlight_anim_y_ = -1.0f; + } - std::vector names; - names.reserve(profiles_.profiles.size()); - for (const auto& profile : profiles_.profiles) { - names.push_back(profile.gamertag.c_str()); + const float value_w = std::min(330.0f * s, content_w * 0.46f); + const float chevron_r = 13.0f * s; + + // Backgrounds first (so the sliding highlight can sit above them), then + // per-row content. + for (int i = 0; i < static_cast(rows.size()); ++i) { + const RowSpec& row = rows[i]; + if (row.kind == RowSpec::kHeader) { + continue; + } + float y0 = columns_y + row_y[i] - content_scroll_; + float y1 = y0 + row_hgt[i]; + if (y1 < columns_y || y0 > content_bottom) { + continue; + } + bool hovered = mouse_in(content_x, y0, content_x + content_w, y1); + if (hovered && mouse_moved && row.enabled) { + zone_ = FocusZone::kContent; + row_index_ = i; + } + ImU32 bg = hovered && row.enabled ? kColPanelHover : kColPanel; + dl->AddRectFilled(ImVec2(content_x, y0), ImVec2(content_x + content_w, y1), bg, 0.0f); + dl->AddRect(ImVec2(content_x, y0), ImVec2(content_x + content_w, y1), kColPanelBorder, + 0.0f); + } + if (content_focus && highlight_anim_y_ >= 0.0f) { + float y0 = columns_y + highlight_anim_y_ - content_scroll_; + dl->AddRectFilled(ImVec2(content_x, y0), ImVec2(content_x + content_w, y0 + row_h), + kColSelFill, 0.0f); + dl->AddRect(ImVec2(content_x - 1.0f, y0 - 1.0f), + ImVec2(content_x + content_w + 1.0f, y0 + row_h + 1.0f), kColAccent, 0.0f, + 0, 2.0f * s); + } + + for (int i = 0; i < static_cast(rows.size()); ++i) { + RowSpec& row = rows[i]; + float y0 = columns_y + row_y[i] - content_scroll_; + float y1 = y0 + row_hgt[i]; + if (y1 < columns_y || y0 > content_bottom) { + continue; + } + float cy = (y0 + y1) * 0.5f; + + if (row.kind == RowSpec::kHeader) { + dl->AddText(bold, header_size, ImVec2(content_x + 2.0f * s, y0 + 8.0f * s), kColAccent, + row.label); + ImVec2 extent = bold->CalcTextSizeA(header_size, FLT_MAX, 0.0f, row.label); + dl->AddRectFilled(ImVec2(content_x + extent.x + 14.0f * s, y0 + 8.0f * s + extent.y * 0.5f), + ImVec2(content_x + content_w, y0 + 8.0f * s + extent.y * 0.5f + 1.0f), + kColRule); + continue; } - BeginFieldRow("Local profile"); - if (ImGui::Combo("##profile", &profiles_.selected_index, names.data(), - static_cast(names.size()))) { - CopyToBuffer(gamertag_buf_, sizeof(gamertag_buf_), - profiles_.profiles[profiles_.selected_index].gamertag); - profile_signed_in_ = profiles_.profiles[profiles_.selected_index].signed_in; - } - EndFieldRow(); + const bool focused = content_focus && row_index_ == i; + const bool hovered = mouse_in(content_x, y0, content_x + content_w, y1); + ImU32 label_col = focused ? kColSelText + : (!row.enabled ? kColTextFaint + : (row.danger ? kColDanger : kColText)); + ImU32 value_col = focused ? kColSelText : (row.enabled ? kColText : kColTextFaint); + ImU32 dim_col = focused ? IM_COL32(60, 70, 74, 255) : kColTextDim; - BeginFieldRow("Gamertag"); - ImGui::InputText("##gamertag", gamertag_buf_, sizeof(gamertag_buf_)); - EndFieldRow(); + // Label. + AddTextVCentered(dl, font, label_size, content_x + 18.0f * s, cy, label_col, row.label); - BeginFieldRow("Local sign-in"); - const char* sign_in_labels[] = {"Signed out", "Signed in"}; - int sign_in_index = profile_signed_in_ ? 1 : 0; - if (ImGui::Combo("##signed_in", &sign_in_index, sign_in_labels, 2)) { - profile_signed_in_ = sign_in_index != 0; - } - EndFieldRow(); + // Value area on the right side of the row. + float vx1 = content_x + content_w - 12.0f * s; + float vx0 = content_x + content_w - value_w; - ImGui::Dummy(ImVec2(0.0f, 8.0f)); - if (PrimaryButton("Save Profile")) { - SaveProfile(); + switch (row.kind) { + case RowSpec::kEnum: { + int count = static_cast(row.options.size()); + int value = count > 0 ? std::clamp(enum_value(row), 0, count - 1) : 0; + ImVec2 left_center(vx0 + chevron_r, cy); + ImVec2 right_center(vx1 - chevron_r, cy); + bool left_hover = row.enabled && + mouse_in(left_center.x - chevron_r - 4.0f, cy - chevron_r - 4.0f, + left_center.x + chevron_r + 4.0f, cy + chevron_r + 4.0f); + bool right_hover = row.enabled && + mouse_in(right_center.x - chevron_r - 4.0f, cy - chevron_r - 4.0f, + right_center.x + chevron_r + 4.0f, cy + chevron_r + 4.0f); + ImU32 chev_circle = focused ? kColAccent : IM_COL32(255, 255, 255, 36); + ImU32 chev_tri = focused ? kColAccentDark : kColTextDim; + DrawChevron(dl, left_center, chevron_r, true, + left_hover ? kColAccent : chev_circle, + left_hover ? kColAccentDark : chev_tri, focused || left_hover); + DrawChevron(dl, right_center, chevron_r, false, + right_hover ? kColAccent : chev_circle, + right_hover ? kColAccentDark : chev_tri, focused || right_hover); + if (count > 0) { + float text_max_w = (right_center.x - chevron_r) - (left_center.x + chevron_r) - + 16.0f * s; + std::string text = + TruncateToWidth(bold, value_size, text_max_w, row.options[value]); + AddTextCentered(dl, bold, value_size, + ImVec2((left_center.x + right_center.x) * 0.5f, cy), value_col, + text.c_str()); + } + if (clicked && row.enabled) { + if (left_hover) { + step_row(row, -1); + } else if (right_hover) { + step_row(row, 1); + } else if (hovered) { + step_row(row, 1); + } + } + break; + } + case RowSpec::kSlider: { + float number_w = 52.0f * s; + float track_x0 = vx0 + 6.0f * s; + float track_x1 = vx1 - number_w; + float t = row.max > row.min ? (*row.value - row.min) / (row.max - row.min) : 0.0f; + float knob_x = track_x0 + t * (track_x1 - track_x0); + ImU32 track_col = focused ? IM_COL32(0, 0, 0, 56) : IM_COL32(255, 255, 255, 40); + dl->AddRectFilled(ImVec2(track_x0, cy - 2.0f * s), ImVec2(track_x1, cy + 2.0f * s), + track_col, 0.0f); + dl->AddRectFilled(ImVec2(track_x0, cy - 2.0f * s), ImVec2(knob_x, cy + 2.0f * s), + kColAccent, 0.0f); + dl->AddCircleFilled(ImVec2(knob_x, cy), 8.0f * s, + focused ? kColSelText : IM_COL32(240, 244, 245, 255), 24); + char value_text[32]; + std::snprintf(value_text, sizeof(value_text), row.fmt, double(*row.value)); + ImVec2 extent = bold->CalcTextSizeA(value_size, FLT_MAX, 0.0f, value_text); + dl->AddText(bold, value_size, ImVec2(vx1 - extent.x, cy - extent.y * 0.5f), value_col, + value_text); + // Mouse drag on the track. + ImGui::SetCursorScreenPos(ImVec2(track_x0 - 10.0f * s, cy - 14.0f * s)); + char slider_id[48]; + std::snprintf(slider_id, sizeof(slider_id), "##slider_%d_%d", category_, i); + ImGui::InvisibleButton(slider_id, + ImVec2(track_x1 - track_x0 + 20.0f * s, 28.0f * s)); + if (ImGui::IsItemActive() && row.enabled) { + float nt = std::clamp((mouse.x - track_x0) / std::max(1.0f, track_x1 - track_x0), + 0.0f, 1.0f); + float new_value = row.min + nt * (row.max - row.min); + // Quantize to step for tidy values. + new_value = row.min + std::round((new_value - row.min) / row.step) * row.step; + new_value = std::clamp(new_value, row.min, row.max); + if (new_value != *row.value) { + *row.value = new_value; + if (row.on_value_change) { + row.on_value_change(); + } + } + zone_ = FocusZone::kContent; + row_index_ = i; + } + if (ImGui::IsItemDeactivated()) { + SaveSimpleSettingsConfig(config_path_); + } + break; + } + case RowSpec::kAction: { + const char* hint = row.enabled ? "Select" : "-"; + ImVec2 extent = font->CalcTextSizeA(15.0f * s, FLT_MAX, 0.0f, hint); + dl->AddText(font, 15.0f * s, ImVec2(vx1 - extent.x, cy - extent.y * 0.5f), dim_col, + hint); + if (clicked && hovered && row.enabled && !row_activated_this_frame) { + activate_row(row); + row_activated_this_frame = true; + } + break; + } + case RowSpec::kText: { + const bool editing_this = editing_text_ && focused; + if (editing_this) { + ImGui::SetCursorScreenPos(ImVec2(vx0 + 6.0f * s, cy - 14.0f * s)); + ImGui::SetNextItemWidth(vx1 - vx0 - 12.0f * s); + ImGui::PushFont(font, value_size); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0, 0, 0, 0.12f)); + ImGui::PushStyleColor(ImGuiCol_Text, + ImVec4(0.05f, 0.06f, 0.07f, 1.0f)); + if (text_edit_focus_pending_) { + ImGui::SetKeyboardFocusHere(); + text_edit_focus_pending_ = false; + } + bool committed = ImGui::InputText("##settings_text_edit", row.text_buf, + row.text_buf_size, + ImGuiInputTextFlags_EnterReturnsTrue); + if (committed || (!ImGui::IsItemActive() && !text_edit_focus_pending_ && + ImGui::IsItemDeactivated())) { + editing_text_ = false; + } + ImGui::PopStyleColor(2); + ImGui::PopFont(); + } else { + const char* text = row.text_buf && row.text_buf[0] ? row.text_buf : "-"; + ImVec2 extent = bold->CalcTextSizeA(value_size, FLT_MAX, 0.0f, text); + dl->AddText(bold, value_size, ImVec2(vx1 - extent.x, cy - extent.y * 0.5f), + value_col, text); + if (clicked && hovered && row.enabled) { + zone_ = FocusZone::kContent; + row_index_ = i; + editing_text_ = true; + text_edit_focus_pending_ = true; + } + } + break; + } + default: + break; + } + } + dl->PopClipRect(); + + // Scrollbar hint when the list overflows. + if (max_scroll > 0.0f) { + float bar_x = content_x + content_w + 4.0f * s; + float track_h = content_view_h; + float bar_h = std::max(24.0f * s, track_h * (content_view_h / rows_total_h)); + float bar_y = columns_y + (track_h - bar_h) * (content_scroll_ / max_scroll); + dl->AddRectFilled(ImVec2(bar_x, columns_y), ImVec2(bar_x + 3.0f * s, columns_y + track_h), + IM_COL32(255, 255, 255, 18), 0.0f); + dl->AddRectFilled(ImVec2(bar_x, bar_y), ImVec2(bar_x + 3.0f * s, bar_y + bar_h), + IM_COL32(255, 255, 255, 90), 0.0f); + } + + // ---- Description panel ---- + { + float panel_h = std::min(content_view_h, 320.0f * s); + float header_bar_h = 38.0f * s; + dl->AddRectFilled(ImVec2(desc_x, columns_y), ImVec2(desc_x + desc_w, columns_y + header_bar_h), + kColAccent, 0.0f, ImDrawFlags_RoundCornersTop); + AddTextVCentered(dl, bold, 16.0f * s, desc_x + 14.0f * s, columns_y + header_bar_h * 0.5f, + kColAccentDark, "Description"); + dl->AddRectFilled(ImVec2(desc_x, columns_y + header_bar_h), + ImVec2(desc_x + desc_w, columns_y + panel_h), kColDescPanel, 0.0f, + ImDrawFlags_RoundCornersBottom); + dl->AddRect(ImVec2(desc_x, columns_y), ImVec2(desc_x + desc_w, columns_y + panel_h), + kColPanelBorder, 0.0f); + + const char* desc_text = kCategories[category_].desc; + if (zone_ == FocusZone::kRail && rail_sel_ == quit_rail_index) { + desc_text = "Quit to the desktop. Unsaved game progress is lost."; + } + const char* note_text = nullptr; + const std::string* extra_text = nullptr; + if (zone_ == FocusZone::kContent && row_index_ >= 0 && + row_index_ < static_cast(rows.size())) { + const RowSpec& row = rows[row_index_]; + if (row.desc) { + desc_text = row.desc; + } + note_text = row.value_note; + if (!row.desc_extra.empty()) { + extra_text = &row.desc_extra; + } + } + float text_x = desc_x + 14.0f * s; + float text_y = columns_y + header_bar_h + 14.0f * s; + float wrap_w = desc_w - 28.0f * s; + dl->AddText(font, desc_size, ImVec2(text_x, text_y), kColText, desc_text, nullptr, wrap_w); + ImVec2 used = font->CalcTextSizeA(desc_size, FLT_MAX, wrap_w, desc_text); + text_y += used.y + 10.0f * s; + if (extra_text) { + dl->AddText(font, desc_size, ImVec2(text_x, text_y), kColTextDim, extra_text->c_str(), + nullptr, wrap_w); + ImVec2 extra_used = + font->CalcTextSizeA(desc_size, FLT_MAX, wrap_w, extra_text->c_str()); + text_y += extra_used.y + 10.0f * s; + } + if (note_text) { + dl->AddText(bold, desc_size, ImVec2(text_x, text_y), kColWarn, note_text, nullptr, + wrap_w); + ImVec2 note_used = font->CalcTextSizeA(desc_size, FLT_MAX, wrap_w, note_text); + text_y += note_used.y + 10.0f * s; + } + if (pending) { + dl->AddText(font, desc_size, ImVec2(text_x, text_y), kColWarn, + "Changes are applied after a restart. Use Apply & Restart when ready.", + nullptr, wrap_w); + } + } + + // ---- Footer button legend ---- + { + float legend_y = io.DisplaySize.y - footer_h + 14.0f * s; + dl->AddRectFilled(ImVec2(rail_x, legend_y - 12.0f * s), + ImVec2(menu_right, legend_y - 12.0f * s + 1.0f), kColRule); + std::vector glyphs; + if (pad_active_) { + glyphs.push_back({"A", "Select", true}); + glyphs.push_back({"B", "Back", true}); + glyphs.push_back({"Y", "Reset to Default", true}); + glyphs.push_back({"LB / RB", "Category", false}); + if (pending) { + glyphs.push_back({"X", "Apply & Restart", true}); + } + } else { + glyphs.push_back({"Enter", "Select", false}); + glyphs.push_back({"Esc", "Back", false}); + glyphs.push_back({"R", "Reset to Default", false}); + glyphs.push_back({"Q / E", "Category", false}); + if (pending) { + glyphs.push_back({"F", "Apply & Restart", false}); + } + } + float x = rail_x; + float glyph_size = 15.0f * s; + float label_text_size = 16.0f * s; + float chip_h = 26.0f * s; + for (const LegendGlyph& glyph : glyphs) { + ImVec2 glyph_extent = bold->CalcTextSizeA(glyph_size, FLT_MAX, 0.0f, glyph.glyph); + float cy = legend_y + chip_h * 0.5f; + if (glyph.circle) { + float r = chip_h * 0.5f; + dl->AddCircleFilled(ImVec2(x + r, cy), r, IM_COL32(232, 236, 237, 235), 28); + AddTextCentered(dl, bold, glyph_size, ImVec2(x + r, cy), IM_COL32(15, 18, 20, 255), + glyph.glyph); + x += 2.0f * r + 8.0f * s; + } else { + float chip_w = glyph_extent.x + 18.0f * s; + dl->AddRectFilled(ImVec2(x, legend_y), ImVec2(x + chip_w, legend_y + chip_h), + IM_COL32(232, 236, 237, 235), 4.0f * s); + AddTextCentered(dl, bold, glyph_size, ImVec2(x + chip_w * 0.5f, cy), + IM_COL32(15, 18, 20, 255), glyph.glyph); + x += chip_w + 8.0f * s; + } + ImVec2 label_extent = font->CalcTextSizeA(label_text_size, FLT_MAX, 0.0f, glyph.label); + dl->AddText(font, label_text_size, ImVec2(x, cy - label_extent.y * 0.5f), kColTextDim, + glyph.label); + x += label_extent.x + 26.0f * s; } } - ImGui::EndChild(); ImGui::End(); - ImGui::PopStyleColor(17); - ImGui::PopStyleVar(7); - ImGui::PopFont(); + ImGui::PopStyleVar(2); } } // namespace rex::ui