From 6c8e885bce4bb3562b46bb5d14bed69bbf9eede1 Mon Sep 17 00:00:00 2001 From: mchughalex <25671999+mchughalex@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:31:14 +0100 Subject: [PATCH] UI: settings menu restyle, physical-px text pipeline, menu blur hooks --- include/rex/graphics/native_guest_renderer.h | 20 + include/rex/ui/fonts_inter.h | 1 + include/rex/ui/imgui_drawer.h | 8 + .../rex/ui/overlay/simple_settings_overlay.h | 4 + src/graphics/d3d12/command_processor.cpp | 25 + src/graphics/native_guest_renderer.cpp | 30 + src/graphics/vulkan/command_processor.cpp | 79 +++ src/ui/fonts_inter.cpp | 129 ++++ src/ui/imgui_drawer.cpp | 70 ++- src/ui/overlay/simple_settings_overlay.cpp | 592 ++++++++++++------ thirdparty/CMakeLists.txt | 28 + thirdparty/imgui | 2 +- 12 files changed, 773 insertions(+), 215 deletions(-) diff --git a/include/rex/graphics/native_guest_renderer.h b/include/rex/graphics/native_guest_renderer.h index 147101a..8625548 100644 --- a/include/rex/graphics/native_guest_renderer.h +++ b/include/rex/graphics/native_guest_renderer.h @@ -68,4 +68,24 @@ bool ShouldSuppressPassAtPitch(uint32_t surface_pitch); // only suppressed scene passes (the native renderer shadows itself). bool ShouldSuppressExemptDepthOnlyDraws(); +// ---- Guest-output post-processor ------------------------------------------ +// Host effect over the EMULATED guest-output path (e.g. the settings-menu +// backdrop blur): invoked by the command processors at the very end of the +// emulated gamma/FXAA refresh, after the guest output image has been fully +// written, with the same context contract as the renderer callback (the +// image arrives in kGuestOutput state and must be returned to it). When the +// native renderer handled the frame this is NOT invoked; the renderer +// applies its own effects inline. Only invoked while the request flag is set +// (a cheap gate so the common no-effect frame skips the RHI frame setup); +// the post-processor clears the flag itself once its effect has fully +// decayed. +using NativeGuestOutputPostProcessor = void (*)(const NativeGuestOutputRenderContext& context, + void* user_data); +void SetNativeGuestOutputPostProcessor(NativeGuestOutputPostProcessor post_processor, + void* user_data); +bool HasNativeGuestOutputPostProcessor(); +void InvokeNativeGuestOutputPostProcessor(const NativeGuestOutputRenderContext& context); +void RequestNativeGuestOutputPostProcess(bool requested); +bool IsNativeGuestOutputPostProcessRequested(); + } // namespace rex::graphics diff --git a/include/rex/ui/fonts_inter.h b/include/rex/ui/fonts_inter.h index 94dcdf5..6d4890b 100644 --- a/include/rex/ui/fonts_inter.h +++ b/include/rex/ui/fonts_inter.h @@ -13,5 +13,6 @@ namespace rex::ui { const char* GetInterRegularCompressedBase85(); const char* GetInterSemiBoldCompressedBase85(); +const char* GetInterBoldCompressedBase85(); } // namespace rex::ui diff --git a/include/rex/ui/imgui_drawer.h b/include/rex/ui/imgui_drawer.h index 7f73981..2e10a59 100644 --- a/include/rex/ui/imgui_drawer.h +++ b/include/rex/ui/imgui_drawer.h @@ -52,6 +52,11 @@ class ImGuiDrawer : public WindowInputListener, public UIDrawer { // default font (PushFont(nullptr, size)). ImFont* ui_font() const { return ui_font_; } ImFont* ui_font_semibold() const { return ui_font_semibold_; } + ImFont* ui_font_bold() const { return ui_font_bold_; } + // Coverage-thinned variants for DARK text on LIGHT backgrounds (see + // SetupFonts); same metrics as the plain fonts. + ImFont* ui_font_on_light() const { return ui_font_on_light_; } + ImFont* ui_font_semibold_on_light() const { return ui_font_semibold_on_light_; } void AddDialog(ImGuiDialog* dialog); void RemoveDialog(ImGuiDialog* dialog); @@ -114,6 +119,9 @@ class ImGuiDrawer : public WindowInputListener, public UIDrawer { // System UI fonts (see ui_font()). Owned by the ImGui font atlas. ImFont* ui_font_ = nullptr; ImFont* ui_font_semibold_ = nullptr; + ImFont* ui_font_bold_ = nullptr; + ImFont* ui_font_on_light_ = nullptr; + ImFont* ui_font_semibold_on_light_ = nullptr; // All currently-attached dialogs that get drawn. std::vector dialogs_; diff --git a/include/rex/ui/overlay/simple_settings_overlay.h b/include/rex/ui/overlay/simple_settings_overlay.h index cdf2901..6e3eca5 100644 --- a/include/rex/ui/overlay/simple_settings_overlay.h +++ b/include/rex/ui/overlay/simple_settings_overlay.h @@ -125,6 +125,10 @@ class SimpleSettingsDialog final : public ImGuiDialog { float highlight_anim_y_ = -1.0f; // smoothed selection highlight position float rail_anim_y_ = -1.0f; float content_scroll_ = 0.0f; + // Swallow the first frame's cursor delta after Show: the pre-open cursor + // position (or a cursor-mode warp) otherwise reads as mouse motion and + // steals focus from the rail to whatever row it lands on. + bool just_shown_ = false; float mouse_x_ = -1.0f; // last mouse position, to detect real motion float mouse_y_ = -1.0f; }; diff --git a/src/graphics/d3d12/command_processor.cpp b/src/graphics/d3d12/command_processor.cpp index 122ae9c..41ce645 100644 --- a/src/graphics/d3d12/command_processor.cpp +++ b/src/graphics/d3d12/command_processor.cpp @@ -2631,6 +2631,31 @@ void D3D12CommandProcessor::IssueSwap(uint32_t frontbuffer_ptr, uint32_t frontbu // presenter so it can submit its own commands for displaying it to the // queue. SubmitBarriers(); + + // Host post-processor over the emulated output (settings-menu + // backdrop blur): runs after the gamma/FXAA pass has fully written + // the image (barriers above put it in the presenter's internal + // state). Frames the native renderer handled returned above; it + // applies its own effects inline. + if (IsNativeGuestOutputPostProcessRequested() && HasNativeGuestOutputPostProcessor()) { + NativeGuestOutputRenderContext native_context; + native_context.backend = NativeGuestOutputBackend::kD3D12; + native_context.guest_output_width = guest_output_width; + native_context.guest_output_height = guest_output_height; + native_context.display_width = display_width; + native_context.display_height = display_height; + if (native_rhi_device_ == nullptr) { + native_rhi_device_ = CreateNativeRhiDevice(this); + } + native_context.device = native_rhi_device_; + native_context.cmd = NativeRhiBeginFrame( + native_rhi_device_, guest_output_resource, + ui::d3d12::D3D12Presenter::kGuestOutputFormat, + ui::d3d12::D3D12Presenter::kGuestOutputInternalState, guest_output_width, + guest_output_height, &native_context.guest_output); + InvokeNativeGuestOutputPostProcessor(native_context); + } + EndSubmission(true); return true; }); diff --git a/src/graphics/native_guest_renderer.cpp b/src/graphics/native_guest_renderer.cpp index 610ae4e..c037e4c 100644 --- a/src/graphics/native_guest_renderer.cpp +++ b/src/graphics/native_guest_renderer.cpp @@ -55,6 +55,9 @@ std::atomic g_renderer_user_data{nullptr}; // True while the registered renderer actually replaced the last presented // frame (false when it yields: menus, early-outs, no renderer). std::atomic g_native_output_active{false}; +std::atomic g_post_processor{nullptr}; +std::atomic g_post_processor_user_data{nullptr}; +std::atomic g_post_process_requested{false}; } // namespace @@ -79,6 +82,33 @@ bool HasNativeGuestOutputRenderer() { return g_renderer.load(std::memory_order_acquire) != nullptr; } +void SetNativeGuestOutputPostProcessor(NativeGuestOutputPostProcessor post_processor, + void* user_data) { + g_post_processor_user_data.store(user_data, std::memory_order_release); + g_post_processor.store(post_processor, std::memory_order_release); +} + +bool HasNativeGuestOutputPostProcessor() { + return g_post_processor.load(std::memory_order_acquire) != nullptr; +} + +void InvokeNativeGuestOutputPostProcessor(const NativeGuestOutputRenderContext& context) { + NativeGuestOutputPostProcessor post_processor = + g_post_processor.load(std::memory_order_acquire); + if (post_processor == nullptr) { + return; + } + post_processor(context, g_post_processor_user_data.load(std::memory_order_acquire)); +} + +void RequestNativeGuestOutputPostProcess(bool requested) { + g_post_process_requested.store(requested, std::memory_order_release); +} + +bool IsNativeGuestOutputPostProcessRequested() { + return g_post_process_requested.load(std::memory_order_acquire); +} + bool IsNativeGuestOutputActive() { return g_native_output_active.load(std::memory_order_relaxed); } diff --git a/src/graphics/vulkan/command_processor.cpp b/src/graphics/vulkan/command_processor.cpp index af4cf7a..753d9cf 100644 --- a/src/graphics/vulkan/command_processor.cpp +++ b/src/graphics/vulkan/command_processor.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -163,6 +164,12 @@ REXCVAR_DEFINE_BOOL(vulkan_gpu_timestamp_buckets, false, "GPU/Vulkan", "with timestamp queries and report them in the FPS overlay") .lifecycle(rex::cvar::Lifecycle::kHotReload); +REXCVAR_DEFINE_INT32(vulkan_gpu_clock_log_interval_frames, 120, "GPU/Vulkan", + "How often (in guest frames) to log the GPU clock/P-state telemetry line; " + "P-state changes and large clock swings log immediately; 0 disables") + .range(0, 36000) + .lifecycle(rex::cvar::Lifecycle::kHotReload); + // Skate 3 ultrawide cvars. These mirror the Direct3D 12 backend's definitions // so the Hor+ classifier behaves identically on Vulkan; the D3D12 translation // unit is not compiled on this platform, so without these the queries below @@ -202,6 +209,51 @@ const char* ReadbackResolveModeName(ReadbackResolveMode mode) { return "unknown"; } +// Periodic GPU clock/P-state telemetry. All timestamp buckets inflating +// together with unchanged interval counts means the same workload is +// executing slower - this line attributes that to core/memory clocks the +// driver kept parked (the sticky post-map-change slow state and the slow +// first seconds of gameplay). Logs on P-state changes and large SM clock +// swings immediately, and unconditionally every interval as a heartbeat. +void LogGpuClockTelemetry() { + const int32_t interval = REXCVAR_GET(vulkan_gpu_clock_log_interval_frames); + if (interval <= 0) { + return; + } + constexpr uint32_t kSampleEveryFrames = 15; + static uint32_t frames_until_sample = 0; + static uint32_t frames_since_log = 0; + static uint32_t last_logged_pstate = UINT32_MAX; + static uint32_t last_logged_sm_mhz = 0; + ++frames_since_log; + if (frames_until_sample > 1) { + --frames_until_sample; + return; + } + frames_until_sample = kSampleEveryFrames; + const GpuClockSample sample = SampleGpuClocks(); + if (!sample.valid) { + // Never loaded or failed - stop paying for the attempt. + REXCVAR_SET(vulkan_gpu_clock_log_interval_frames, 0); + return; + } + const uint32_t sm_delta = + sample.sm_mhz > last_logged_sm_mhz ? sample.sm_mhz - last_logged_sm_mhz + : last_logged_sm_mhz - sample.sm_mhz; + const bool state_changed = sample.performance_state != last_logged_pstate || + sm_delta * 5 > last_logged_sm_mhz; // >20% swing. + if (!state_changed && frames_since_log < uint32_t(interval)) { + return; + } + REXGPU_INFO("Vulkan GPU clocks: sm={}MHz mem={}MHz pstate=P{} util={}% memutil={}% " + "throttle={:#x}", + sample.sm_mhz, sample.mem_mhz, sample.performance_state, sample.gpu_util_percent, + sample.mem_util_percent, sample.throttle_reasons); + frames_since_log = 0; + last_logged_pstate = sample.performance_state; + last_logged_sm_mhz = sample.sm_mhz; +} + bool IsGameplayStateActive(const system::KernelState* kernel_state) { if (!kernel_state) { return false; @@ -2736,6 +2788,8 @@ void VulkanCommandProcessor::IssueSwap(uint32_t frontbuffer_ptr, uint32_t frontb return; } + LogGpuClockTelemetry(); + // In case the swap command is the only one in the frame. if (!BeginSubmission(true)) { REXGPU_ERROR("XELOG_GPU PRESENT: BeginSubmission FAILED"); @@ -3473,6 +3527,31 @@ void VulkanCommandProcessor::IssueSwap(uint32_t frontbuffer_ptr, uint32_t frontb ui::vulkan::VulkanPresenter::kGuestOutputInternalLayout); } + // Host post-processor over the emulated output (settings-menu + // backdrop blur): runs after the gamma/FXAA pass has fully written + // the image (the pending release barrier above is flushed by the + // RHI frame begin). Frames the native renderer handled returned + // above; it applies its own effects inline. + if (IsNativeGuestOutputPostProcessRequested() && HasNativeGuestOutputPostProcessor()) { + NativeGuestOutputRenderContext native_context; + native_context.backend = NativeGuestOutputBackend::kVulkan; + native_context.guest_output_width = guest_output_width; + native_context.guest_output_height = guest_output_height; + native_context.display_width = display_width; + native_context.display_height = display_height; + if (native_rhi_device_ == nullptr) { + native_rhi_device_ = CreateNativeRhiDevice(this); + } + native_context.device = native_rhi_device_; + // ever_written = true: the pass above just wrote the image, so it + // is in the presenter's internal layout once barriers flush. + native_context.cmd = NativeRhiBeginFrame( + native_rhi_device_, vulkan_context.image(), vulkan_context.image_view(), true, + guest_output_width, guest_output_height, &native_context.guest_output); + InvokeNativeGuestOutputPostProcessor(native_context); + NativeRhiEndFrame(native_rhi_device_); + } + // Need to submit all the commands before giving the image back to the // presenter so it can submit its own commands for displaying it to the // queue, and also need to submit the release barrier. diff --git a/src/ui/fonts_inter.cpp b/src/ui/fonts_inter.cpp index 83741c3..12d05b7 100644 --- a/src/ui/fonts_inter.cpp +++ b/src/ui/fonts_inter.cpp @@ -370,6 +370,131 @@ static const char kInterSemiBold_compressed_data_base85[17270+1] = "'`vQNxLF&#i^v8/MXZY#P;PQ')>MMFDiu(/G#####m8AtBC(t/"; +// Exported using binary_to_compressed_c.exe -base85 "InterMenu-Bold.ttf" kInterBold +static const char kInterBold_compressed_data_base85[16985+1] = + "7])#######02xYI'/###[),##1xL$#Q6>##TZ;995BEL%?Ver6Rf>11uRdL<(jlS;-_*87_qEn/3ExF>'aGI_u*m<--@^01uZn42Y)*q),2MY5xj*R/aNV=B-b;1EkR3$6u,>>#Hb(*H" + "MY`=-%U^C-8BRm/#-0%JR`p-PgLPH2DQT`3G-d@kP]4vu8$$43JuBd3fCRGXI)44#691+K[^I7RJ=#GZp%48Mfn0+>00F" + "lq:T3WKIV64R_B#,%S+HWUU7#Y#iH-5OD21V9Xc2FR=;dW2>8%CxMd%aCZV-jnlV-,/vS/#K)w$_iQC#`J#j#MG72H<2T7`A2?X.Q5Zp8nP.WtV?VcuFJO0N9nPmS$<)=GS1b(N*wAvP" + "LI?>#0c><%xjt1K81pR*iepR*gb1`s',TSSa;vofI2/T*PTp:mu.OJi>H)S*[a8x9RsVcM0)wQj:^'%M8TW$#jE9D3&0fX-BijfLQTZO(Re#,M(FR(<$)3fLR*Io.'02Ap@$Z_$3da4S" + "QdaLu$3P+V8]Oig&5>##YH:;$hdP[.Nke%#@N[U0H&B.*Ynn8%0x]fL)_i8.'HSF4+87<.2UnhMX/IA.GZRkr'M-/>p^-Gm3GLR#ag6QN#QSdq<+IPEwu;^ibqXR+.6+5V._$XE:Uex@" + "2>o'@0X&##-^@)vY4Dg$'*+&#oD,W.KJ=l([eX@$B2Z8/gG+jLJLihL_sgd4T(r;$TY7ouYFiQ#x#HP#PaF[u6m`AL-'oQZn$c@#ax;ku0TPQ#bn@X#EAx>pxr[huT0vvtOabXqe*P:v" + "2'Lo@=.__#S`($#*Mc##KLM0vR(x+MG^%dDk$@9&6]###&vmanuYWe)>Jc>#Z'GT.VvmbrnQ6V#nEYluBD=Ze95r'i(&Mg]SL.2o4,MQ#`KV[t%Sei.YH:;$+M#@.bE9D3qHUa3S:EVm" + "EH(/LZ#=M^M9*uu>?p@OccEVMf]qPScaW5',^6pf?81;Hj2ZSf6O1T*o/`%Xu8%#QEq>jhBE4;H*+Q9i&B1pLfO<^Odc4" + "ZH7g)@]Cv#)0_=csjxu5Lc:/([/P7egi/HZ'.p>VD((c`7Mdku&s(R#YO.X#?BLKlGKC,vS*DsuC_=cn4GlRn.0/rL`c5vG^CDW-2dXL#]'gc276,)3gk%SIfs9EXScC+2$AJ(NR3mA#" + "ZLL@-77ggLQ1M>?>>b2ZSf7`):2D^Sw9DT.S*rGTf:D5B>#TJM1pKT,_JdV###NBd)4MfXI)" + "*)TF4LkQF7u,d]4WJSX#qIr`?e6q^u[f^Wl[MPLFnIL)*;8`CrdM,W-w^Ce6UP###0,E:/uR>c4t$TfL_J2K(mmGT.hAAr:^?JVH4-dV6hfQr?lwEpu2OH;quf+t^-" + "*Vp?9ip3@9RH`hL`C#,MxcT.1:<]Y-c_5L,_^@L,-$l]#]1f]44n@X-Y_vNFfCI8%#9#g1m`l]#6Un`%B6_-(?J8L(pYNNps:S4fGp)&+A7`Es/X=f(S&H]#YdMMg=IOqS$,C@[E+*;Z" + "d'/Da1;DJA1c$84$b/Da%AwCC%u255;t,WS7#,sQUpK,*H+GB#7HT7/#_kEI>%TfL5)C+*HJ:(>1_d9Ai1M?#Z%Alu00eQ:=rbgLZ0gK:?C91kh5-&MHebc8lv%NNE(Mx=idWr$V8iG%" + "&f''#A####SuaT#M7C/#.^@)v<1Bm$o*V$#2;J('&uXe)@]Cv#J29f3?;Rv$fT=x#nj#Yc$kxu5@9^Y5XJ>uc@Ln)h)XoPN#f@;.Mru@u-;Wd*N%J4l$9g)bNRhL:#[PoQaRhUSSa;vof5ODx9a2ZSf&q-T*^l]`ql&8c###f7K,3#DXI)t29Z-PA**GQfh69edsvK`i[FrD]*7NeDUMg[/wcR" + "G/.A+Wcu1Gup'##.^@)v)0Bm$sB%%#%px4(1+RX-urkV-DwE^6_,E.3>5#9'om.7iSrd#K0nv`EjD%3[(nnmZe&Rm:xO/ouJ$#fqY$WG)#CoHJ/Ue;-&76&%HHG3bDKu3b1R+naqiXV-" + "7#dNb;E8f3m%hGNkNIY#Dw%L#c7]lu,4pU#wJIuLn0p_sTOHX4Hr[G)rerP&EOl/#$'D(v(B5)McqtjLRFqV.B(C+*D%sM0i+39/ND#G4:d^O9cp:Z>iL0+*r/l]#R4)Z#j.&tuiZ220" + "rK+BJ`iBmWG^7MuBp>[O[3ik%jg%vKm2=@K5?hLg;MGWGKq[l;@l'k13bo*eR-:AM7<=6;V&$Y-.ABDKfFRfL?7KnLMfcgL5Qe##7r2]-6n@X-EE#&b)miOfN9eXu$GBJhx2qGNjab>>" + "SVL/)n^Uv-%o-lLsp6u%^9s;$#Otmu1agc2;koLYF[RjuNBZqr9rU7uKLwOYT/s4u)PT$OR(4GM62rPSx#'/1*c68%bnOD*BnuS*D=)Giu.OJi1w(S*M55x99JhxF+/C57WG2MgKc2/n&&65qIlfM=B1ga3(H2m0dj#(^Q(#*Mc##xB9U#MO:h.BH:;$U>dD-'Q@h%" + "8^@A4G$Ts$iF3]-E+Rs$u.'J3$+rf#%s#81@%YauV]=Iu.dV52ShLF#`bM>>ITH%$-_+6ufTp.^,su,PU$##PNv)4MtP%.`w]79c=A=%+n-H3AS'f)XXj=.3:,-3" + "rQ;Z>@4[s$iL0+*R=d?h[mbFQTEc(?7c^Lpbsb+`S>`hu3J&-WK&wt(C?%a=Sl:@YnL8D8i8.BT'a4IjT&5vW_S*%-oFi" + "XcM=-kY$=.&31-g)smofYcM=-*cD&.as'XO6e3bNS*@B;T++,M+N_]tlUDJ1E[,87KcCW-15CQ(Tv/o8-c5g)h,r]$=tJs$=]d8/jZ_,)a0K>fv." + "E]*Cl>8J8F./aLu*+n1GTr%A#Vf0xt:Xwj7FhsMZcpfp:nD)s$**Y5J6'N6*;P^WUD-g&63vN3LsmW/uMG[4OUqbnS:=,G>'lFU?k33KNaY>@1vSMg'Ld9A9T9>#GWM:vAQAhRmXv)Td/2RE1ib)QogYqUdfvYutZYC.Hoh;HOcF'#vdu'v(o9F$" + ":aaj$V,.&4(n%J3v<'Y$C>>#Ed1&M)`@5/e<%##ZAO(j`2,F%=]2eHnFl]O" + "Daj)N?/C+*2xs^oXk5Q'Br/K<3v',+j$Q#KLMBWK:PD?#7t&+([fgK#ZN(-HM'79)fkFf_eXi.LE[B_#0++U]11NPqoI>^^oXhV`4i=(.bB0sLB#K%#FPUV$'@ho-NBp2Dx'-&%6%joI" + "(>`qM:BQ]pba$W$9UNFGvNf30biWo7+$psH5t;6*euZo[iiwrQ[A:7>iVsv77%Xl#J&-qM`gQT-0Sfb-`:J?pEordm#q7fhvdM=-]0+r.P`39#^l$T*ffCDRk;r%Vhgp7U_%##vZYbuUWGJ$]FX&#DeG@.auvs.BFEp.2=Ss$`fD?[gSA+4pZ'u$<3ie&" + "H+;>Gqwpo.WP:@GIjJR*.'d[Kf1E^f#foQ#" + "O-'U#JeL(K#kK.hsxm#J$NY4JZX-i^YueUcEf,8R(sL,vnnWrL/Lo0%pd''#q####GZKEq?M#F@RXXM$/6>##)LEn3#jkl%lUSM>ZA)FIfD###'M4I)O?Z)4I5^+4[nqakAv40;;0^_/9d9(pO@R:vhR4X_2M)Y_BY.h$2w^dM'j^L;Y%cNbs?3AFcUO$B" + "X=oFVFbl`$(e,V#?d[@u+L:GB:'`qr.c76/SQ>n$LrJkL.+p+M9M%pecGSV-:*5H*JjE.37dG,*h1qAO>VK4LtDYK1WWp^;&IJYT*tSBD&uUduU;ILZPJce?5/%*&##QKvLg]_0WfdUTvL8Xs'%.6t29TV'?H#8`qMMrle$bivAJ]ZQ&JKa6H2" + "_21rZ_;@rQPxucR$rcGRoPtG%SY=^+;akY?g&iih<9).Mg`iX-W[sq)ab[PSh.;x0xVNj`@O&-%['fU.P`39#9MAu-$6/#P]Q," + "P[JJ(/M51M7oxF4[4pL(6'1T%/ewK#WxQYQL3=L),l0ON[&_@D74rbrsP&UDQiB>#]VmOo.ifKYk=)W-t)CUBI=WT/*C'Y.:d^O93Qi/:k>.d)El:Z##PU@t.61l0#@OEQNS_R#YVT+p" + "J=++)/di6C).hS#2m2A*9Ls3U`vZc,+10(W8BS:2m@_/3lJLn/g]I1X;TQt$3D1b;W$pG3F[hE[lf@lD(Mp;-OSF4%((e)O?@S#.,9nENZ)%0Q''mrC]>6shmxd>CXw=cH=BJKFcA-H$" + "a6^BC*oj-HV7q6&t/IZTsGMM1iF7fxCM*oJwu;r/m$$AhhL@]Lk)IGEF3/86J*u;Tv-^gx9.iNAX-_>pCg>bsP(gQsT1_QW^T^hF5A>U8^DwYg+(pn.k#a8D5&plF>#A6H+r" + "[YI5A6]###`I*^-;5$Up(^pZ-Xl:Z#R3qT#%[^vAu0h^u>k2[Z/*do#japRD1HPf#2vBenpQBuuiQ5w.m-@5/=5hd+wwSfLbpj.L,Bjxt>K(E+.NTe;#D,H3[jTv-5(aE[:-VN(Ir1tI" + "RatJ#MIqQK5=PjL<#$$01g]##t,ffGL%foRou,*XUVZn&=p&3osT%Vsb+`r>7870Nqj(,boh_mQ_.C@0xb`N291<" + "M(sl8sjV9i^)Jv$Z3RA-W?qi;`)E^4CxkQ#YtKI2FmfI*X(%1CCb:f)uc]H#k[=muV&bR/1XZK)Vb%du8_bHK+JQH#-+vZAunOsh+xXatj(YA:>R/;^>XO4MfptjLGSLsL7,Mm(g:a$[" + "oq+>%a>+eu'TJQCS<[(+8]PI#_r7P#rUr*KT6@c*I*og#4#QV[37V+4x6LPuCBO(h.%2f3tkX^Mp@Qu>gPr'AU#5J*3YGc4qvcT%;Ah8.)0j>9xLVA&2W7H#V^b=-/tid*#1<`u9.w6Y" + ";u.VHb0l@5o_C2Bd6,_Q=RJ>:.@Cu,[ln$#BAxu,-Mpv$gd''#Ls`Y$%>Fr)Wt#r)t3$Pfo:0L,jd9SIvPdA,`][m/bE9D3Z;2]-:eS@#R]fuiJbA#)2fSL5KU/1;Hs)%h9F&#Q,>>#>wGl$H@OK1eO39/Xe_a4iVp.*M':au" + "6Smt$H<]f_b^2AMH8WBpoaQ>NXoc3($_6cu7X`,B&`OlZE+sXcn1,cFC8a#K+Wl1NLe:g^3J'pN8?QAp8ggC>5=#nODPL-WIu(Ot,>Qi>J5NsNil%v#Znfx4])1jT^q-eQ.l$##RCRW." + "B(C+*1$c@7hof+46*l]#vB&_S=F%],RTF=$*tqHM#N+_/C[Rs$eKx[#dEuXlZ]V<%%/.m`@]*oQ6#nhJZ*MO%UHK9KUa#fqXwl1FbIF>AqFma'xh0hF'7(S*8hQ_KsF^j^q&jlaYexv&" + "17-@,'@Yma,EYQ#B?1q:*p?A#c>pu&V]gK)PnH+s&HI2?3]uZ21]7t/u?#LU(j]R&@&@mLF5tN@TOS?S]`'" + "[,pO:Fg8iBj8FL3I0pZY]iclSF#[>#9[3eHZ`6eH=8`$';ORX-8gfX-2E9N(VRG)4/)8N0[H1;?Bs?;6^#nqu$pB^uxnWpu$Ia`u?vV1^_'E+`A/Cf1JEsluR+BkLv]NigcjQ&#" + "5p1M3,Wd;#)F9D3G#TF4uKZ)4/h;E4D;6T.0)xD**VC_%f$:u$^>8+4MfXI)S^Ng=)Q%0UD*NmJ_iZR#1OfRnmdd7Db_K`NU:Zv#@@k:mm'dbW`e*l;k#H[Uk1fE_SU@0GT%3uf-W)xKv(EqLlXR$hck2-:nc]sDFmVPMTqIs&,66@+49j/_%8*RalnOAx$h6eX-r6B/(3cqKC*Hwr);iR_=`Lph08P7(SFMY>p)vdC6&M" + "%:Xr$;$v&>KqF$.%:;W-/ghU83sIvPlXB+*VRr;$W:Dnt@SBD3;9ILFQ*tNOr2WFIvXai#8uTYP-Ub,n_R:QW`3L:vDW=`LT6-D@Gn@YFPHwqC-&'VFE=YYKXnnd)ZiGKcA05##8QUV$" + "^%F9#<(V$#mOwC#HH/i)4ctM(:3(B#eLZ]FoB5$`fLRm&H(%p%C.F9u>nv_s&sot#:tQ%'='0s@&b72U&D=RqKxsF0xXQp.80fX-W(5X&w6G#%#:U#q3^MM0,Lq,GRDU]8YF$ipGFwKu" + "F1Xm/'@bwg7]#c)27C-?i5VDIM.%L208ptuP7]TKf<'dnZBmX$%c>n$@8iX%)DW8C`_J@>*e0^#*$+l`lG@Sa^R(%FKRIqZZu0IeG?8L)*3h[A$gt2S[X';slFk+]W8" + "L$$FYZlVVti7fnGdTM@@bnEIBvUl9PtBJtq;=,YT#4-R:d6F3t=[%##]ihv7/%gG3ZjH)4^HRo[iU`+2tvR7RPZN#uM[I+2&5>##s0ST$Vvx/.0qvC>x:Gd3QGg+40=3<--B>x1wBG$$/Uc6sfFcTocGI?5qVe0IJh1Qwu,#[]/." + "^o,98:E0j(1T,t-^P;;?)^:E4.S39/O/WN;fG,wJ4FVx'0FStHa7gE&vlNpMGTD4taWisXbn?+r*:X>9p0?R:/$O8**5ambY+E$st(53o#&%Qs:utKq9Hon=T" + "x^j%=kpBv>0g88.5cNY5xaaD#X/=K+U=lx-C#IpWPnd$ubXI)euc@#Vo`7<1=71q7%6Cu5;#'=Q;P6i+M" + "lZArQV5(sHrU`/)vv;f6o5c@#$I%:NhNArQ_si>$KTq1MRbqP1e6]T$(IQ;#lvK'#rX2eHijq&$co&W&hm?d)T-OG5.';_>k-Z2E00TG$3#*,MC2Toj-ff*?xEQ3X.b/" + "M*`0_)4V,jhLXU>hL64&Gr@kGq8(f,7:hW=;-[h['M@J(905#GSR^uoA#g]0b%luo=YAbg;-/(" + "klVb3J%E]-BHqc)s@9F*lE->Y'SBF#x_Rh4`@gbuh#]h#;l$,D-TBvT059l]4ir_s-s#bg>$E>#.op?9k$@@9TYOl(Ke4`#.'R,*q7XI)nfWd=UIb1D$[TI;2'<[Kwj^R#2;xJL_VPF#" + "2xvfuo?'6XjM98ganWB-mpWB-TpWB-lk/S38BF:.3UUZBqhrguQ]FkERV;a-0_pE@H,&rC2sS?0S4/I#&DFJCDh6[KR/5##1_RF%M;6[S&Vt>UA/wE+iK.vju,a6K#qsH37[DXFr#lYdH0N>fuSQ.AXqq#v,K+&eL6W$D_]uF*V" + "t8_w@W/nGRW=(e4kY?Q2Eu(rb@_.m7f4ru_@VG#e'NY0CxIp$Y.W<#QDpkLaad8/i>169w,bc4$=_C4*^B.*:>`:%m&Cu$NW^:%(f)Q/F5h#eb,?4*Yug3aa#K4ORi>,4" + "vAwV;^5M9_+F^%T@:I>#SqRiPxO5=EB2%,H@3WMb4p-])bRDhPes$BC'V(@RfZL]6l^=jP'aW?De;uGdD->>#`FGV-Dv;a*`S_$'wKkA#EA=V-VfYxkC=.o%" + "o9m--WgEV->aN5]Y%.O(bhE5&lKV5]$'SsQ*-)NhC2u=lCMSk+Y8###4n@X-hbtM(Zk:Zus0.m/flq$%[Zub#Bb8F->R):1@;FV-`uPxkR2n?tImK8/L.89$e_]_MBRfX-6K$CuN0-Fq" + "BoRfLSDTT$=6Su-QV@+MFtr=-F9Su-VlN_Na3tO#a&q#$Gm'hL*PA7&1h'u$iRMS?JXR`QhPsi>t-qf(EQ-(&5mw%RMZQt.2nqfCQ0DwgrA*.)c;;@_1/?X(DF7oR,YVl+int%Xb[B3_" + "f:.L,&3sfC%lSL5rdK#$N_YV-]Exu-PlRfLY7&,Vm>&:/jL(##q]GiT.C)##l+LkFw.=cM#uGWML.[N#em`=-#b`=-&s@u-_w,]M5'jfuBno=#cOc##8U[stMFuG-;GuG-wMj>0ltlV-" + ",RPD_LQ9bIL)>>#fDqJ#0t+`MA]Jstb78R0h%&t#mqA*#4Z$8@eZlH3?DXI)ulIS:cM;nLl,WkBGLB6Mi_$mL'>f8@ovd8/wi`9MgR](W[%2F%FG[(W;3IkOoMs>$Je).-5=c&#/$;]j)#Mrgo.GKkA#eNvru&FgM#KlN#7lsLCDHYukjd8/" + "RU>eZoS+JhpYj/;Ra_ip2qx%Y2qx%Ym_$XLN-b4#5uIL$jNc##mOwC#KXR@#/`G]t$%v1^J3eX#sapquRkI7na0l9Mj$]w0h'xw0F^9[0Ur%9.GHSr$$3]+;Pde+i)O$v,Lwi?#_]kug,XLeMQ6#6UqN$-g1$#%$AX-if*F360fX-nuv_u$,egu$s?iKF20=q$H,SIc69sL$I95#7TEP$@mWX%m7QP/^%3D#=xG>#" + "5[uUZ5D7R#3vnx_#urx=5BoP#M4K['87F['^1+nu7#%P`&B%VZ[cGD/NmE=$D:&vL^9W$#kkgk._kJu.CUxr%L>c)Ohc45]'#k@t)Bf9:%0At9rNUQ9hA=Se$S9J#9@l&uWntT:ws`<9" + "Ia&qr5`-#jKx`Tm'iZ)FNMGF:.j^Qu5]vQgu$5W+MPKj-$LIODo$&5>#brxXu.D:*MkK1PSt/&,i#]x%+7*Da<:jE.3'`B+*;b5V/@rh8.d.<9/?;Rv$7H3sZr`em<9Bw)4V0f5%" + ",W@qKMCTu.I[R>#ADf95_e>E=]h'R6V:b,<**,##sPB>$?,M$#,pia-hOL-Q^s7Hu%rsL#3RY.q(jRR4$&>uuec*9#&5>##%0fX-(KI8uQ=4)MxW?'$Ou[h$]<4gLuQDD3n0M8.JrDD3" + "51,=n`CI4$(%IKuXf=muBd2q/U=p.$((F9#xI1_%^=3Q/gY5J*>ln]4`W^:%;):`j2EeH#/>8MBM37%)rJS:v0sZ[#mo^.Vr=VgY4vw[u%t(DK=/&kubI:;$5'lr?U+wrmYC18.9oNY5" + "Gcfw-h?7f3twC.3m]WI)It9w.`<>V/=A6gDQucG*NdQT%J`tYgoP[PVg1/vgpS[PVVEsibEGL1IFGIGHuH)A@VM[,;PJ@KDR%/d,J3iu?%UePVt+cw0ouO5TVJ4" + "WPFT#]kS60K>uu#a^T4ol#qon`65s7Ab$gL990C4Ob7e43T&vPAV=D<]#mG*YL^c&F$0[#`r29/n-&ku'TbDIYK'.V^M-.M>3cOo-HC9QhtnVMbqhXu&@.;Cq7s/h-BW&?]=c]7nO%h&" + "`$+eGJYf[PH=0C-Ubq1M7e'^#)/a6GT$:H5>Vdc>(Mw:AwoL" + "9fX`#]1r_$004&#O,>>#YBB9.vBo8%YPpE8f18TSFW;eiLu$T4*Zv4-=*F6]KG>jO3vAGo/EiRr/iiw,6xjI7emTgKW#%oV'#BQa3" + "](/(#6rN88It]]Y7Ma3k-nB*.PmJPIPtE3D67N" + "FtD+2^)HW-XApQ$?Ud;#JuJ*#mJc8/XkC+*I5^+4u%ws..W8f3Og;[.[ZY8&9`^F*jR'f)KIw8%Q6;jT#DSC4Bhp:/H`wv$L#[)*8Moi`0Ng.($ex'?ZVP@tYT#oEmg.)m;j^Q-xCx0<" + "_LP;9eY5@J(OAJCa>c((`?P%$CC`Tp`Bu[$[)2au&PMw19n=^6*YrbEikgQDiC,]QK1p-]1hj3;-NIm:pHhjUn(wd$P.+4>4*+d#DOJ_4M&R,,1HA_4T5@g+>hD]F3TH>5>9=8%k&1P(" + ",jv;%v.F>5kY:-gN-K=c8*>Kch*vL1YgplJ8TPAb=r###x;eR_GD4jLCU-1MhE0k$tZMJ#Rpjfr'/#X3^5:@+0Z->U]w'u*$Pf" + "MZMMg^q1hPmHxnL)*I7$lueR&P-5Oe:Zl1$#$[o#$2IEKD'D'#m5YY#p#B*#nu9H':U'U%%wWS.Hv#gn#`s%Bg3=qL$##" + "?3`b*Y&RabqEY05##16`P-9D,n%#ufi':Ne+4FF+F3>HF:.omx9." + "36HF#+BiWBQVw8[cRXJ1c'44G#ZTS7P,B$#tQW`_8d81F%x^MJC*c68%T=oG)ANf>-)4Gv-YOfVM26KnL>ZLY$24RA-)wEB-`''$.Ex&kL[qQ##w(oP%$cv;#/93jL" + "R7](#lPUV$rZv)48Rb,%1AXI)lQ%;Q^XZ>#7R9)3oM4gL*k]@#?n)w$5m,v:A;WqL(r8_Q+5V%'r9N##d/fuGbC&79%lb@$FRl)3Q0Mu?f6(%@YKK>SfvA>S/BuDOGaCE3C?VMghH4Ab" + "94SI4cAS.<87e_j7Fh#v&F=M$cvh$%wb8ZfA7AcVmhJ88_`%qf#)P:v+sN`s9j@^+`g4L>#/V5%$IA*.)'hAoOdkBQ$Gom_uk%4GN" + "I$650[IT/)dV@T#jTrh,x@'DNI0QP0;.=`#somk$cwh$MMeGi$7Dt&%v3UhLulix-`GUv-4IuD4*^B.*+2pb4el:Z#>21W90Z1^#CqV%A%^7$.c%81$(lg`37G,nKwcD7B-$U:vhVxZ$" + "]'F9#M$kd;&tFg.:&+##]?q4.uWQIM`,ck%&>rM:iFhB#^eKD?NT')?.W^b%Xe_D$@^SA6FXSA65)9gL$(1I$<(T2%5b.?Pmb75/mEvv8.b[E$DP:Z71ZjfLZRCP854QDET*SS%6/*9j9au-w%3`7n&>LNAJJp[&JXE7Q3hYK+4aaPp%dsxHPdv+IPJwwh2]&4.3SCc15V,wO$eB`$#=H?D*ep<=Z$dSWR" + "cc35oMR'8/L>gQ$'CxRN7iIwDD(vq1>RB^@ou#-3b%xlAjrWk+cr2]-Fm#g1B_nieqx8;-(1I202B*1#@A+.#fY-@'>:th#fAX)W&Q?(#V7p*#1tI-#&Lg2#B%>Y#]D6C#gnuF#ATOI#" + "%r&M#mTTU#YP]t#3.rZ#w1N`#@ake#I$kp#h<7w#fvg#$@]A&$qBr($W7:3$&aK>$[N7D$M5hF$(rAI$YD;o$k%W[$vY'b$3D_j$at&m$8TMo$f,i$%hhH0%`+(R%bS0B%d4WD%>q1G%" + "3[$o%uiVW%PU:Z%*6b]%dXAa%XPbe%r7x1&JETq%hH[w%>#$$&n_S&&sBW-&6H^6&Zd:C&XmuJ&hZWP&`>2S&L<[W&fhvc&Ql'j&8Hpm&@K($'mS53'7e+>'>[JE'FR=Q'T^UZ'-2O*(" + "+qOu'HUdG(:5T6(t6/A(DxgF(`?hN(x6t&)Uq@p(*'Rs(ubVw(VTC$)ERm()9a:G)rOk.)L6E1)#ml3)Y'17)F&0:)vUM<)hMn@)J4HC)FM@H)FNsL)&2KU)f:0^)i?8a)qQQg)Rg`@*" + "RR71*^Ui8*dR9F*_uJQ*$C+U*rRn`*Mgfe*<4Fi*tlst*>xYv*_-Ax*?An@+KxgKM4hUa4'B_=HAudO^))-%u$cGc*dF?>#'G:;$Ig^-B3$vs']G?8%Y9w0#r=5^RaLRm/I>oPu=U_f%" + "1Dh<.(/5##)=dZ$][:_$;NCUS(WPg12Ww/;F'PPo_w).$AB.DNDHX/$,X-DN%t%F.u10DN@iu-$ML4m#*nqAN:]6J$pAl^$n?EY.8=YF%f'l?->]P8.];t&%8x,Z$nKx>-Pn,D-9kA$0" + "fQ[`$vkU_$rSjvPDoT=#,O]nL-`e;#c=W'M,oL5/=A6G$OkEWSAcuGkCsx4T>/58L/O9iDs%5A?gJ]F6I#2BM3_lAC&p=GM)P`GU`<'5u+;[l>f_g:x=Yw<&hV.%E5&JV)GVR=AloN(EAYkIdfVj@HJVvH4aX`,+##" + "xOP8.QjCG).[V8&[bniLbLfS8,owlB[Bv92^RlE$-0k=$a/`($=R?(#+c68%J<%%#Q@g1t0H'c)eA;YP1Ei:#$_Yb#:CP##`4a@$[)A>-vfNj0]E](apX(##0ohv#5IC^#*w,tL0_CaN" + "I'7f=eq'GV4GQ8/6c#<%V]l)M8xSfLN_;;$1jm&MmJG&#gI3#.SxefLc^/%#V(4GMQUd>#NU#<-P>Ib3V>$(#0rH0#EIFV-?T=>-6mC#vc?cS08,3)#=aeM-L8tbN$*9nL=o_P/2]'##" + ".=iIK)4jJ)bGTK2WSElf?tixF6oWh#%:P9#D'Z3#(>&:fu>2?ZlA##,Mc##6f1$#@(V$#D4i$#R_R%#RR@%#n5=&#mWt&#lEX&#IC]5#t2h'#+?$(#3dZ(#P*]-#Avv(#D>N)#D8E)#" + "N]&*#[%T*#[oA*#n[P+#nO>+#E7D,#4[%-#3I`,#.Jf5#>H4.#NTF.#Q#(/#)st.#WqG3#d?)4#`3m3#W:4gLnL?##_RFgLwS#oL1F%q..`($#YT__&OrQS%GkC#$@P`PB>h-?$5OTxX" + "1@6Z$s$k^oST@v$)&%jq+/G]FW&9sI@Lf?Kk]4GD(+p>-FY)kbv:v?0e&hx=B$:NC7n;J:-co3+P_`'/U+;kFlY2R3mJ8JCr`v9)S%co7x)0F%Apv9)+7auG9bK88IdMdEg'X`3#4*R<" + "UpYc2q#n+DNHn>H4Y3>5[*E,39:]PBqGZMCrr^G375#E=o+Uc;YY6#6?MpM1&EcdO`P;=-hi?2MMA]qLH`4rLbiu0OusMa-U?*1#XB:7#SGNvPPL-##$)>>#alRfL?ecgLw1K;-..m<-" + "pRrt-31SnLCa?+#tSGs-^g$qL0(8qL[$TfL$$YrLqKM/#CTGs-CABsLqp.0#ITGs-gX;uLZAm6#3UGs-&Dt$Mg4/8#>UGs-7ug%Mv9c9#KCg;-.e&g1Iww%#1;gl8`Yx+#^14,M'(7:M" + "%@c(N7v('#'/=GM8gtaEhpf.#iJLW.#####+`6o%vLY.#"; + const char* GetInterRegularCompressedBase85() { return kInterRegular_compressed_data_base85; } @@ -378,4 +503,8 @@ const char* GetInterSemiBoldCompressedBase85() { return kInterSemiBold_compressed_data_base85; } +const char* GetInterBoldCompressedBase85() { + return kInterBold_compressed_data_base85; +} + } // namespace rex::ui diff --git a/src/ui/imgui_drawer.cpp b/src/ui/imgui_drawer.cpp index d86d060..ea453ae 100644 --- a/src/ui/imgui_drawer.cpp +++ b/src/ui/imgui_drawer.cpp @@ -29,6 +29,7 @@ #include #include +#include namespace rex { namespace ui { @@ -370,10 +371,38 @@ void ImGuiDrawer::SetupFonts() { // loaded before them - fonts cached here would dangle. { ImFontConfig config; + // FreeType rasterization WITHOUT hinting: light hinting's blue-zone + // snapping rounded the x-height down at the menu sizes and read as + // vertically squished (playtest). Unhinted FreeType keeps the design + // proportions exactly while still rasterizing curves cleaner than stb. + // Advances stay fractional (browser letter spacing); the Oversample + // fields are ignored by the FreeType loader. + config.OversampleH = 1; + config.OversampleV = 1; + config.PixelSnapH = false; + config.RasterizerMultiply = 1.0f; + config.FontLoaderFlags = ImGuiFreeTypeBuilderFlags_NoHinting; ui_font_ = io.Fonts->AddFontFromMemoryCompressedBase85TTF( GetInterRegularCompressedBase85(), 18.0f, &config); ui_font_semibold_ = io.Fonts->AddFontFromMemoryCompressedBase85TTF( GetInterSemiBoldCompressedBase85(), 18.0f, &config); + ui_font_bold_ = io.Fonts->AddFontFromMemoryCompressedBase85TTF( + GetInterBoldCompressedBase85(), 18.0f, &config); + // DARK-ON-LIGHT variants: naive sRGB alpha blending renders dark text on + // light panels measurably fatter and softer than the browser (stems 4.94 + // vs 4.70 px, vertical edge gradient 73 vs 92) - the + // browser's text engine gamma-adjusts coverage per polarity. A coverage + // multiply < 1 approximates that for dark-on-light; light-on-dark keeps + // the plain 1.0 fonts above. Metrics are identical across variants. + ImFontConfig config_on_light = config; + // 1.0 = currently identical to the plain fonts: the 0.8 coverage-thinning + // experiment read as "lighter, not sharper" in playtest. Kept as the + // per-polarity tuning knob. + config_on_light.RasterizerMultiply = 1.0f; + ui_font_on_light_ = io.Fonts->AddFontFromMemoryCompressedBase85TTF( + GetInterRegularCompressedBase85(), 18.0f, &config_on_light); + ui_font_semibold_on_light_ = io.Fonts->AddFontFromMemoryCompressedBase85TTF( + GetInterSemiBoldCompressedBase85(), 18.0f, &config_on_light); } auto add_first_available = [&io](const std::vector& paths) -> ImFont* { for (const std::string& path : paths) { @@ -450,6 +479,15 @@ void ImGuiDrawer::SetupFonts() { if (!ui_font_semibold_) { ui_font_semibold_ = ui_font_; } + if (!ui_font_bold_) { + ui_font_bold_ = ui_font_semibold_; + } + if (!ui_font_on_light_) { + ui_font_on_light_ = ui_font_; + } + if (!ui_font_semibold_on_light_) { + ui_font_semibold_on_light_ = ui_font_semibold_; + } 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_); } @@ -533,13 +571,21 @@ void ImGuiDrawer::Draw(UIDrawContext& ui_draw_context) { ImGuiIO& io = ImGui::GetIO(); -#if defined(__APPLE__) - // Retina: glyphs must rasterize at display pixel density to survive the - // logical->physical magnification in RenderDrawLists. Per-frame because the - // window can move between displays with different densities. - float fb_scale = float(window_->GetDpi()) / float(window_->GetMediumDpi()); - io.DisplayFramebufferScale = ImVec2(fb_scale, fb_scale); -#endif + // The UI coordinate space is PHYSICAL pixels, 1:1 with the render target - + // no logical->physical magnification anywhere in the UI path. Under a + // fractional OS scale (e.g. Windows 150%) a logical coordinate space cannot + // be blur-free: the GPU stretch lands half the pixel-snapped positions + // between physical pixels, and glyph texels can never all align. Glyphs now + // rasterize at their physical size directly, so density compensation + // (DisplayFramebufferScale, the old Retina fix) must stay 1. + io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f); + // Widget-based dialogs (message boxes, wizards, fps overlay) author their + // font sizes in logical units - FontScaleDpi keeps their on-screen size + // across DPI scales while text rasterizes at physical resolution. Explicit + // ImDrawList::AddText sizes (the settings overlay) are unaffected by it. + // Per-frame because the window can move between monitors with different + // scales. + ImGui::GetStyle().FontScaleDpi = float(window_->GetDpi()) / float(window_->GetMediumDpi()); uint64_t current_frame_time_ticks = rex::chrono::Clock::QueryHostTickCount(); io.DeltaTime = @@ -551,9 +597,8 @@ void ImGuiDrawer::Draw(UIDrawContext& ui_draw_context) { } last_frame_time_ticks_ = current_frame_time_ticks; - float physical_to_logical = float(window_->GetMediumDpi()) / float(window_->GetDpi()); - io.DisplaySize.x = window_->GetActualPhysicalWidth() * physical_to_logical; - io.DisplaySize.y = window_->GetActualPhysicalHeight() * physical_to_logical; + io.DisplaySize.x = float(window_->GetActualPhysicalWidth()); + io.DisplaySize.y = float(window_->GetActualPhysicalHeight()); ImGui::NewFrame(); @@ -859,8 +904,9 @@ void ImGuiDrawer::OnKey(KeyEvent& e, bool is_down) { void ImGuiDrawer::UpdateMousePosition(float x, float y) { auto& io = GetIO(); - float physical_to_logical = float(window_->GetMediumDpi()) / float(window_->GetDpi()); - io.AddMousePosEvent(x * physical_to_logical, y * physical_to_logical); + // MouseEvents already carry physical pixels (WindowPointToPhysical) - the + // UI coordinate space is physical too, no conversion. + io.AddMousePosEvent(x, y); } void ImGuiDrawer::SwitchToPhysicalMouseAndUpdateMousePosition(const MouseEvent& e) { diff --git a/src/ui/overlay/simple_settings_overlay.cpp b/src/ui/overlay/simple_settings_overlay.cpp index 01b93a6..c2eb0a4 100644 --- a/src/ui/overlay/simple_settings_overlay.cpp +++ b/src/ui/overlay/simple_settings_overlay.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -78,21 +79,41 @@ constexpr float kRepeatDelay = 0.42f; constexpr float kRepeatRate = 0.11f; // ---- Palette ------------------------------------------------------------- +// Frosted-panel color scheme (iterated against +// real gameplay captures): teal frosted side panels with white text over the +// blurred scene, WHITE setting rows with near-black text, black focused row +// with a lime ring, lime section/description bars, magenta steppers/slider. -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 kColSelFill = IM_COL32(10, 12, 12, 255); +constexpr ImU32 kColSelText = IM_COL32(250, 252, 252, 255); +// Rows are OPAQUE (no scene bleed-through) but tuned to the tone the +// raw white would render (~235 vs 252) so they don't read as +// too bright or too contrasty. +constexpr ImU32 kColPanel = IM_COL32(235, 236, 236, 255); +constexpr ImU32 kColPanelHover = IM_COL32(248, 250, 250, 255); +constexpr ImU32 kColPanelBorder = IM_COL32(0, 0, 0, 26); +constexpr ImU32 kColRowText = IM_COL32(15, 17, 18, 255); +constexpr ImU32 kColRailPanel = IM_COL32(14, 56, 53, 140); +constexpr ImU32 kColRailPanelHover = IM_COL32(24, 90, 86, 166); +constexpr ImU32 kColRailBorder = IM_COL32(255, 255, 255, 26); +constexpr ImU32 kColText = IM_COL32(235, 242, 241, 255); +constexpr ImU32 kColTextDim = IM_COL32(224, 235, 234, 191); +constexpr ImU32 kColTextFaint = IM_COL32(150, 158, 158, 255); +constexpr ImU32 kColAccent = IM_COL32(213, 235, 10, 255); +constexpr ImU32 kColAccentDark = IM_COL32(13, 15, 5, 255); +constexpr ImU32 kColInteract = IM_COL32(230, 0, 120, 255); +constexpr ImU32 kColInteractHover = IM_COL32(255, 25, 145, 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); +constexpr ImU32 kColDescPanel = IM_COL32(11, 46, 43, 140); +constexpr ImU32 kColChevDisabled = IM_COL32(0, 0, 0, 56); +// The disabled chevron on the FOCUSED (black) row needs a light variant - +// black-alpha disappears there. +constexpr ImU32 kColChevDisabledOnDark = IM_COL32(255, 255, 255, 64); +constexpr ImU32 kColLegendChip = IM_COL32(238, 240, 240, 255); +constexpr ImU32 kColLegendText = IM_COL32(15, 18, 20, 255); +constexpr ImU32 kColLegendLabel = IM_COL32(228, 236, 235, 255); +constexpr ImU32 kColWhite = IM_COL32(255, 255, 255, 255); bool HasCvar(std::string_view name) { return rex::cvar::GetFlagInfo(name) != nullptr; @@ -296,6 +317,13 @@ void SetTearingCvars(bool value) { // ---- Small draw helpers ---------------------------------------------------- +// Snap to whole pixels. Fractional origins under bilinear filtering smear +// glyph edges and turn 1px rules into soft 2px lines - browsers pixel-snap +// text and borders, so snapping to whole pixels keeps them crisp. +float Snap(float value) { + return std::floor(value + 0.5f); +} + 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; @@ -310,24 +338,157 @@ std::string TruncateToWidth(ImFont* font, float size, float max_width, std::stri 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, + dl->AddText(font, size, + ImVec2(Snap(center.x - extent.x * 0.5f), Snap(center.y - extent.y * 0.5f)), col, text); } +// Optical centering for short glyph labels (pad button circles, key chips): +// AddTextCentered centers the line box, but a capital's visual ink sits +// asymmetrically inside it (descender slack below, bearings on the sides), so +// the letter lands ~1px down-right of a small circle's center. Vertical +// centers the CAP-HEIGHT BAND taken from 'H' +// rather than each glyph's own ink: per-glyph raster boxes round +// independently (A has a pointed-apex overshoot, Y doesn't), which lands +// adjacent buttons on different subpixel rows - the eye catches that +// inconsistency more than any absolute offset. One shared band = identical +// placement for every label, overshoots hanging evenly, which is also what +// the browser's hinted rasterizer converges to. Horizontal centers the ink +// for single glyphs (circles) or the advance box when ink_x is false - +// multi-glyph chip labels match the browser's text-box centering that way. +// ASCII labels only. +void AddTextCenteredCap(ImDrawList* dl, ImFont* font, float size, ImVec2 center, ImU32 col, + const char* text, bool ink_x) { + ImFontBaked* baked = font->GetFontBaked(size); + float pen = 0.0f; + float min_x = FLT_MAX, max_x = -FLT_MAX; + for (const char* p = text; *p; ++p) { + const ImFontGlyph* glyph = baked->FindGlyph(ImWchar(uint8_t(*p))); + if (!glyph) { + continue; + } + if (glyph->Visible) { + min_x = std::min(min_x, pen + glyph->X0); + max_x = std::max(max_x, pen + glyph->X1); + } + pen += glyph->AdvanceX; + } + if (min_x > max_x) { + return; // no visible ink + } + float band_top = 0.0f; + float band_bottom = size; + if (const ImFontGlyph* cap = baked->FindGlyphNoFallback(ImWchar('H'))) { + band_top = cap->Y0; + band_bottom = cap->Y1; + } + const float x = ink_x ? center.x - (min_x + max_x) * 0.5f : center.x - pen * 0.5f; + // Round-half-DOWN for the vertical position: the cap band regularly has a + // half-integer center (e.g. 18px band in a 52px circle), and Snap's + // round-half-up resolved every tie downward - measured exactly 1px low + // versus the browser, which resolves the same tie upward (the typographic + // convention: when a glyph can't center exactly, err high). + const float y = center.y - (band_top + band_bottom) * 0.5f; + dl->AddText(font, size, ImVec2(Snap(x), std::ceil(y - 0.5f)), col, text); +} + 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); + dl->AddText(font, size, ImVec2(Snap(x), Snap(center_y - extent.y * 0.5f)), col, text); +} + +// Justified paragraph (browser text-align: justify): wraps words to wrap_w +// and stretches the word gaps of every full line so both edges align; the +// last line of the paragraph stays ragged-left. Returns the height consumed. +float AddTextJustified(ImDrawList* dl, ImFont* font, float size, ImVec2 pos, float wrap_w, + ImU32 col, const char* text) { + std::vector words; + for (const char* p = text; *p;) { + while (*p == ' ') { + ++p; + } + const char* start = p; + while (*p && *p != ' ') { + ++p; + } + if (p > start) { + words.emplace_back(start, size_t(p - start)); + } + } + if (words.empty()) { + return 0.0f; + } + const float space_w = font->CalcTextSizeA(size, FLT_MAX, 0.0f, " ").x; + auto word_w = [&](std::string_view w) { + return font->CalcTextSizeA(size, FLT_MAX, 0.0f, w.data(), w.data() + w.size()).x; + }; + float y = pos.y; + size_t i = 0; + while (i < words.size()) { + float line_w = word_w(words[i]); + size_t j = i + 1; + while (j < words.size()) { + const float w = word_w(words[j]); + if (line_w + space_w + w > wrap_w) { + break; + } + line_w += space_w + w; + ++j; + } + const bool last_line = j == words.size(); + float gap = space_w; + if (!last_line && j - i > 1) { + const float words_only = line_w - space_w * float(j - i - 1); + const float stretched = (wrap_w - words_only) / float(j - i - 1); + // Conditional justify: only stretch when the gaps stay modest - + // beyond ~1.6x a normal space the stretched line reads worse than a + // ragged one (the "big spaces" objection to naive justification). + if (stretched <= space_w * 1.6f) { + gap = stretched; + } + } + float x = pos.x; + for (size_t k = i; k < j; ++k) { + dl->AddText(font, size, ImVec2(Snap(x), Snap(y)), col, words[k].data(), + words[k].data() + words[k].size()); + x += word_w(words[k]) + gap; + } + y += size; + i = j; + } + return y - pos.y; +} + +// Focused-item highlight: rounded black fill with a lime ring and a thicker +// black outer edge, both drawn OUTSIDE the box (stacked +// box-shadows). Total ring = 6*s beyond the rect on every side; callers keep +// a matching margin in their clip rects. +void DrawFocusHighlight(ImDrawList* dl, ImVec2 p_min, ImVec2 p_max, float s) { + const float radius = 6.0f * s; + dl->AddRectFilled(p_min, p_max, kColSelFill, radius); + // Lime band: box edge -> +2*s (stroke centered at +1*s). + dl->AddRect(ImVec2(p_min.x - 1.0f * s, p_min.y - 1.0f * s), + ImVec2(p_max.x + 1.0f * s, p_max.y + 1.0f * s), kColAccent, radius + 1.0f * s, 0, + 2.0f * s); + // Black outer edge: +2*s -> +6*s (stroke centered at +4*s). + dl->AddRect(ImVec2(p_min.x - 4.0f * s, p_min.y - 4.0f * s), + ImVec2(p_max.x + 4.0f * s, p_max.y + 4.0f * s), kColSelFill, radius + 4.0f * s, 0, + 4.0f * s); } // 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) { + center = ImVec2(Snap(center.x), Snap(center.y)); if (filled) { dl->AddCircleFilled(center, radius, circle_col, 24); } else { dl->AddCircle(center, radius, circle_col, 24, 1.5f); } + // Tip-anchored triangle (tip reaches further from center than the back + // edge). A bbox-centered variant was tried and looked worse in playtest - + // keep this geometry. float t = radius * 0.42f; float dir = points_left ? -1.0f : 1.0f; ImVec2 tip(center.x + dir * t, center.y); @@ -447,6 +608,7 @@ void SimpleSettingsDialog::Show() { highlight_anim_y_ = -1.0f; rail_anim_y_ = -1.0f; prev_pad_buttons_ = 0xFFFF; // swallow buttons already held at open + just_shown_ = true; // swallow the stale cursor delta too } void SimpleSettingsDialog::LoadSettingsFromCvars() { @@ -591,7 +753,12 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { rows.clear(); const bool pending = HasSettingsChanges(); + // Section bars only SEPARATE groups - a category's first group + // starts directly with its rows, so a leading header is dropped. auto header = [&rows](const char* label) { + if (rows.empty()) { + return; + } RowSpec row; row.kind = RowSpec::kHeader; row.label = label; @@ -600,7 +767,7 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { switch (category) { case 0: { // Video - header("DISPLAY"); + header("Display"); if (!device_list_.device_names.empty() && HasCvar(device_list_.cvar_name)) { RowSpec row; row.kind = RowSpec::kEnum; @@ -711,7 +878,7 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { rows.push_back(std::move(row)); } if (HasFieldOfViewCvar()) { - header("GAMEPLAY"); + header("Gameplay"); RowSpec row; row.kind = RowSpec::kSlider; row.label = "Field of View"; @@ -735,7 +902,7 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { break; } case 1: { // Controls - header("MOUSE & KEYBOARD"); + header("Mouse & Keyboard"); { RowSpec row; row.kind = RowSpec::kEnum; @@ -763,7 +930,7 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { break; } case 2: { // Profile - header("LOCAL PROFILE"); + header("Local Profile"); { RowSpec row; row.kind = RowSpec::kEnum; @@ -810,7 +977,7 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { break; } case 3: { // System - header("SESSION"); + header("Session"); { RowSpec row; row.kind = RowSpec::kAction; @@ -990,10 +1157,34 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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; + // Coverage-thinned variants for dark text on light panels (rows, section + // bars, legend chips) - matches the browser's per-polarity text gamma. + ImFont* bold_ol = imgui_drawer()->ui_font_semibold_on_light() + ? imgui_drawer()->ui_font_semibold_on_light() + : bold; // Uniform scale: design metrics are authored for 1080p. const float s = std::clamp(io.DisplaySize.y / 1080.0f, 0.6f, 3.0f); + // Quantize font sizes so the EM (size * upm / (hhea ascent - descent) - + // the browser's font-size) lands on whole pixels. A fractional em renders + // measurably wider letter spacing than the browser reference, which snaps + // css font sizes to the device grid (+1.3% run width at 4K, 370px vs + // 365px on "Vertical Synchronisation"). + constexpr float kEmPerSize = 2048.0f / 2478.0f; // Inter upm / (asc - desc) + auto font_px = [](float size) { + return std::round(size * kEmPerSize) / kEmPerSize; + }; + + if (just_shown_) { + // Ignore the pre-open cursor position delta (opening warps/reveals the + // cursor): it otherwise reads as motion and steals focus from the rail + // to whatever row happens to sit under the cursor. + mouse_x_ = io.MousePos.x; + mouse_y_ = io.MousePos.y; + just_shown_ = false; + } + // ---- Input ---- NavIntents in = GatherInput(io); if (editing_text_) { @@ -1067,15 +1258,17 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { row.on_enum_change(value); } }; + // Range semantics: directional stepping (dpad/arrows/chevrons) + // CLAMPS at the ends - the matching chevron greys out there - while + // activation (A/Enter/row click) cycles with wrap-around. 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); + int value = std::clamp(enum_value(row), 0, count - 1); + set_enum_value(row, std::clamp(value + dir, 0, count - 1)); } 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) { @@ -1089,9 +1282,14 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { return; } switch (row.kind) { - case RowSpec::kEnum: - step_row(row, 1); + case RowSpec::kEnum: { + int count = static_cast(row.options.size()); + if (count > 0) { + int value = std::clamp(enum_value(row), 0, count - 1); + set_enum_value(row, (value + 1) % count); + } break; + } case RowSpec::kAction: if (row.action) { row.action(); @@ -1169,33 +1367,39 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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; + // Positional metrics are snapped to whole pixels (see Snap). + const float margin_x = Snap(std::max(56.0f * s, io.DisplaySize.x * 0.05f)); + const float title_y = Snap(io.DisplaySize.y * 0.13f); + const float title_size = font_px(42.0f * s); + const float columns_y = Snap(title_y + 74.0f * s); + // Rail and description columns are equal-width. + const float rail_w = Snap(300.0f * s); + const float desc_w = Snap(300.0f * s); + const float col_gap = Snap(14.0f * s); + const float footer_h = Snap(96.0f * s); + const float content_bottom = Snap(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); + content_w = Snap(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 rail_x = Snap(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 float row_h = Snap(52.0f * s); + const float header_h = row_h; // section bars match setting rows + // Gap is 1 design px tighter than the 6*s focus ring, so the ring slightly + // overlaps neighbours - it draws above them. + const float row_gap = Snap(5.0f * s); + const float rail_item_h = Snap(52.0f * s); + const float label_size = font_px(19.0f * s); + const float value_size = font_px(19.0f * s); + const float desc_size = font_px(18.0f * s); + // Description panel height - also anchors Close Game's bottom edge. + const float desc_panel_h = + Snap(std::min(content_bottom - columns_y, 320.0f * s)); const ImVec2 mouse = io.MousePos; const bool mouse_moved = @@ -1224,56 +1428,27 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { } 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); - } + // ---- Backdrop: uniform slightly-dark tint over the whole screen. The + // native renderer blurs the finished frame while the menu is open + // (SetSettingsMenuBlur), so the tint lands on a frosted scene. + dl->AddRectFilled(ImVec2(0.0f, 0.0f), io.DisplaySize, IM_COL32(6, 9, 11, 133)); // ---- 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); - } + dl->AddText(bold, title_size, ImVec2(Snap(rail_x), title_y), kColText, "Settings"); if (pending) { const char* chip_text = "RESTART REQUIRED TO APPLY"; - float chip_size = 14.0f * s; + float chip_size = font_px(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; + float chip_x0 = Snap(chip_x1 - extent.x - 2.0f * chip_pad); + float chip_y0 = Snap(title_y + title_size * 0.5f - extent.y * 0.5f - 6.0f * s); + float chip_y1 = Snap(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->AddText(bold, chip_size, ImVec2(Snap(chip_x0 + chip_pad), Snap(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) { @@ -1292,30 +1467,25 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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); + // The rounded focus fill covers this item while rail-focused; skip the + // square panel underneath so its corners don't poke past the radius. + bool rail_focused = zone_ == FocusZone::kRail && rail_sel_ == i; + if (!rail_focused) { + ImU32 bg = is_current ? kColSelFill : (hovered ? kColRailPanelHover : kColRailPanel); + 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), kColRailBorder, 0.0f); } } - // Focused rail item: sliding white fill (with the accent bar attached to - // its left edge) drawn over the panels, then labels. + // Focused rail item: sliding black fill with the lime focus ring, 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); + const float fill_y = Snap(rail_anim_y_); + DrawFocusHighlight(dl, ImVec2(rail_x, fill_y), + ImVec2(rail_x + rail_w, fill_y + rail_item_h), s); } else { rail_anim_y_ = -1.0f; } @@ -1324,7 +1494,7 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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, + AddTextVCentered(dl, bold, 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. @@ -1337,7 +1507,8 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { // 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; + // Bottom edge aligned with the description panel's bottom. + float y0 = Snap(columns_y + desc_panel_h - rail_item_h); 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); @@ -1345,14 +1516,10 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { Hide(); close_game_(); } - ImU32 bg = focused ? kColDanger : (hovered ? kColPanelHover : kColPanel); + ImU32 bg = focused ? kColDanger : (hovered ? kColRailPanelHover : kColRailPanel); 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); - } + focused ? kColDanger : kColRailBorder, 0.0f); AddTextVCentered(dl, bold, label_size, rail_x + 20.0f * s, (y0 + y1) * 0.5f, focused ? IM_COL32(255, 250, 249, 255) : kColDanger, "Close Game"); } @@ -1389,9 +1556,13 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { } } content_scroll_ = std::clamp(content_scroll_, 0.0f, max_scroll); + const float scroll = Snap(content_scroll_); - dl->PushClipRect(ImVec2(content_x, columns_y - 2.0f), ImVec2(content_x + content_w + 1.0f, - content_bottom), + // Clip window padded by the focus-ring extent (6*s) so the ring can sit + // fully OUTSIDE the focused row without being cut off at the edges. + const float ring_pad = 6.0f * s; + dl->PushClipRect(ImVec2(content_x - ring_pad, columns_y - ring_pad), + ImVec2(content_x + content_w + ring_pad + 1.0f, content_bottom + ring_pad), true); // Sliding selection highlight (white fill under the focused row). @@ -1409,14 +1580,16 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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. + // Backgrounds first, then per-row content in TWO phases around the sliding + // highlight: non-focused content below it, the focused row's content above + // it - the ring overlaps neighbours (gap is 1 design px tighter than the + // ring) and must read as the forefront element. 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 y0 = columns_y + Snap(row_y[i]) - scroll; float y1 = y0 + row_hgt[i]; if (y1 < columns_y || y0 > content_bottom) { continue; @@ -1426,49 +1599,46 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { zone_ = FocusZone::kContent; row_index_ = i; } + // The rounded highlight covers the focused row's panel entirely; skip it + // so its square corners don't poke past the radius. + if (content_focus && row_index_ == i) { + continue; + } 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) { + auto draw_row_content = [&](int i) { RowSpec& row = rows[i]; - float y0 = columns_y + row_y[i] - content_scroll_; + float y0 = columns_y + Snap(row_y[i]) - scroll; float y1 = y0 + row_hgt[i]; if (y1 < columns_y || y0 > content_bottom) { - continue; + return; } 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; + // Section header: solid accent bar spanning the content + // column, dark bold text, label column-aligned with the row labels. + dl->AddRectFilled(ImVec2(content_x, y0), ImVec2(content_x + content_w, y1), kColAccent, + 0.0f); + AddTextVCentered(dl, bold_ol, label_size, content_x + 18.0f * s, cy, kColAccentDark, + row.label); + return; } const bool focused = content_focus && row_index_ == i; const bool hovered = mouse_in(content_x, y0, content_x + content_w, y1); + ImFont* row_bold = focused ? bold : bold_ol; // polarity-matched weight 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; + : (row.danger ? kColDanger : kColRowText)); + ImU32 value_col = focused ? kColSelText : (row.enabled ? kColRowText : kColTextFaint); // Label. - AddTextVCentered(dl, font, label_size, content_x + 18.0f * s, cy, label_col, row.label); + AddTextVCentered(dl, row_bold, label_size, content_x + 18.0f * s, cy, label_col, row.label); // Value area on the right side of the row. float vx1 = content_x + content_w - 12.0f * s; @@ -1480,26 +1650,31 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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 && + // Magenta filled stepper circles with white triangles; + // a circle greys out (and stops responding) at its end of the range. + const bool can_left = row.enabled && value > 0; + const bool can_right = row.enabled && value < count - 1; + bool left_hover = can_left && 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 && + bool right_hover = can_right && 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; + const ImU32 chev_disabled = focused ? kColChevDisabledOnDark : kColChevDisabled; DrawChevron(dl, left_center, chevron_r, true, - left_hover ? kColAccent : chev_circle, - left_hover ? kColAccentDark : chev_tri, focused || left_hover); + can_left ? (left_hover ? kColInteractHover : kColInteract) + : chev_disabled, + kColWhite, true); DrawChevron(dl, right_center, chevron_r, false, - right_hover ? kColAccent : chev_circle, - right_hover ? kColAccentDark : chev_tri, focused || right_hover); + can_right ? (right_hover ? kColInteractHover : kColInteract) + : chev_disabled, + kColWhite, true); 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, + TruncateToWidth(row_bold, value_size, text_max_w, row.options[value]); + AddTextCentered(dl, row_bold, value_size, ImVec2((left_center.x + right_center.x) * 0.5f, cy), value_col, text.c_str()); } @@ -1509,7 +1684,7 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { } else if (right_hover) { step_row(row, 1); } else if (hovered) { - step_row(row, 1); + activate_row(row); // row-body click cycles with wrap-around } } break; @@ -1520,18 +1695,17 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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); + ImU32 track_col = focused ? IM_COL32(255, 255, 255, 77) : IM_COL32(0, 0, 0, 56); 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); + kColInteract, 0.0f); + dl->AddCircleFilled(ImVec2(Snap(knob_x), Snap(cy)), 8.0f * s, kColInteract, 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); + ImVec2 extent = row_bold->CalcTextSizeA(value_size, FLT_MAX, 0.0f, value_text); + dl->AddText(row_bold, value_size, ImVec2(Snap(vx1 - extent.x), Snap(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]; @@ -1560,10 +1734,8 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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); + // No right-side 'Select' / '-' hint: the footer legend covers + // activation, and disabled rows read from their dim label alone. if (clicked && hovered && row.enabled && !row_activated_this_frame) { activate_row(row); row_activated_this_frame = true; @@ -1575,10 +1747,13 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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)); + // value_size is already physical; divide out the global widget-font + // DPI scale (it exists for logical-unit dialogs, see ImGuiDrawer). + ImGui::PushFont(font, value_size / std::max(0.01f, ImGui::GetStyle().FontScaleDpi)); + // Edit field sits on the BLACK focused row: light text, light frame. + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(1, 1, 1, 0.15f)); ImGui::PushStyleColor(ImGuiCol_Text, - ImVec4(0.05f, 0.06f, 0.07f, 1.0f)); + ImVec4(0.98f, 0.99f, 0.99f, 1.0f)); if (text_edit_focus_pending_) { ImGui::SetKeyboardFocusHere(); text_edit_focus_pending_ = false; @@ -1594,8 +1769,8 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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), + ImVec2 extent = row_bold->CalcTextSizeA(value_size, FLT_MAX, 0.0f, text); + dl->AddText(row_bold, value_size, ImVec2(Snap(vx1 - extent.x), Snap(cy - extent.y * 0.5f)), value_col, text); if (clicked && hovered && row.enabled) { zone_ = FocusZone::kContent; @@ -1609,6 +1784,22 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { default: break; } + }; + // Phase 1: everything except the focused row. + for (int i = 0; i < static_cast(rows.size()); ++i) { + if (!(content_focus && row_index_ == i)) { + draw_row_content(i); + } + } + // Phase 2: the sliding highlight above neighbours... + if (content_focus && highlight_anim_y_ >= 0.0f) { + float y0 = columns_y + Snap(highlight_anim_y_) - scroll; + DrawFocusHighlight(dl, ImVec2(content_x, y0), ImVec2(content_x + content_w, y0 + row_h), + s); + } + // ...phase 3: the focused row's own content above the highlight. + if (content_focus && row_index_ >= 0 && row_index_ < static_cast(rows.size())) { + draw_row_content(row_index_); } dl->PopClipRect(); @@ -1626,17 +1817,16 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { // ---- Description panel ---- { - float panel_h = std::min(content_view_h, 320.0f * s); - float header_bar_h = 38.0f * s; + const float panel_h = desc_panel_h; + const float header_bar_h = row_h; // header bar matches a settings row 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, + kColAccent, 0.0f); + AddTextVCentered(dl, bold_ol, label_size, desc_x + 18.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); + ImVec2(desc_x + desc_w, columns_y + panel_h), kColDescPanel, 0.0f); dl->AddRect(ImVec2(desc_x, columns_y), ImVec2(desc_x + desc_w, columns_y + panel_h), - kColPanelBorder, 0.0f); + kColRailBorder, 0.0f); const char* desc_text = kCategories[category_].desc; if (zone_ == FocusZone::kRail && rail_sel_ == quit_rail_index) { @@ -1655,37 +1845,34 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { 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 text_x = Snap(desc_x + 14.0f * s); + float text_y = Snap(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; + text_y = Snap(text_y + + AddTextJustified(dl, font, desc_size, ImVec2(text_x, text_y), wrap_w, + kColText, desc_text) + + 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; + text_y = Snap(text_y + + AddTextJustified(dl, font, desc_size, ImVec2(text_x, text_y), wrap_w, + kColTextDim, extra_text->c_str()) + + 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; + text_y = Snap(text_y + + AddTextJustified(dl, bold, desc_size, ImVec2(text_x, text_y), wrap_w, + kColWarn, note_text) + + 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); + AddTextJustified(dl, font, desc_size, ImVec2(text_x, text_y), wrap_w, kColWarn, + "Changes are applied after a restart. Use Apply & Restart when ready."); } } // ---- 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); + float legend_y = Snap(io.DisplaySize.y - footer_h + 14.0f * s); std::vector glyphs; if (pad_active_) { glyphs.push_back({"A", "Select", true}); @@ -1705,29 +1892,30 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { } } float x = rail_x; - float glyph_size = 15.0f * s; - float label_text_size = 16.0f * s; - float chip_h = 26.0f * s; + float glyph_size = font_px(15.0f * s); + float label_text_size = font_px(16.0f * s); + float chip_h = Snap(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; + x = Snap(x); 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); + dl->AddCircleFilled(ImVec2(x + r, cy), r, kColLegendChip, 28); + AddTextCenteredCap(dl, bold_ol, glyph_size, ImVec2(x + r, cy), kColLegendText, + glyph.glyph, /*ink_x=*/true); x += 2.0f * r + 8.0f * s; } else { - float chip_w = glyph_extent.x + 18.0f * s; + float chip_w = Snap(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); + kColLegendChip, 4.0f * s); + AddTextCenteredCap(dl, bold_ol, glyph_size, ImVec2(x + chip_w * 0.5f, cy), + kColLegendText, glyph.glyph, /*ink_x=*/false); 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); + ImVec2 label_extent = bold->CalcTextSizeA(label_text_size, FLT_MAX, 0.0f, glyph.label); + dl->AddText(bold, label_text_size, ImVec2(Snap(x), Snap(cy - label_extent.y * 0.5f)), + kColLegendLabel, glyph.label); x += label_extent.x + 26.0f * s; } } diff --git a/thirdparty/CMakeLists.txt b/thirdparty/CMakeLists.txt index 93e2180..f076b0d 100644 --- a/thirdparty/CMakeLists.txt +++ b/thirdparty/CMakeLists.txt @@ -225,6 +225,29 @@ endif() #============================================================================= include(${PROJECT_SOURCE_DIR}/cmake/rexglue_fidelityfx.cmake) +#============================================================================= +# FreeType - font rasterizer for imgui (fetched on demand, all optional deps +# disabled: pure static rasterizer). Enables hinted glyph rendering - the +# stb_truetype loader cannot grid-fit horizontal strokes, which measurably +# softens UI text versus browser rendering. +#============================================================================= +include(FetchContent) +set(FT_DISABLE_ZLIB ON CACHE BOOL "" FORCE) +set(FT_DISABLE_BZIP2 ON CACHE BOOL "" FORCE) +set(FT_DISABLE_PNG ON CACHE BOOL "" FORCE) +set(FT_DISABLE_HARFBUZZ ON CACHE BOOL "" FORCE) +set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE) +FetchContent_Declare(freetype + GIT_REPOSITORY https://github.com/freetype/freetype.git + GIT_TAG VER-2-13-3 + GIT_SHALLOW TRUE) +FetchContent_MakeAvailable(freetype) +# Drop the version .rc: it's DLL metadata only, and rc.exe has no include +# paths under the clang/ninja toolchain (fatal RC1015 on windows.h). +get_target_property(_ft_sources freetype SOURCES) +list(FILTER _ft_sources EXCLUDE REGEX "\\.rc$") +set_property(TARGET freetype PROPERTY SOURCES ${_ft_sources}) + #============================================================================= # imgui - Immediate mode GUI (no CMakeLists.txt, manual target) #============================================================================= @@ -235,11 +258,16 @@ add_library(imgui OBJECT ${IMGUI_DIR}/imgui_draw.cpp ${IMGUI_DIR}/imgui_tables.cpp ${IMGUI_DIR}/imgui_widgets.cpp + ${IMGUI_DIR}/misc/freetype/imgui_freetype.cpp ) add_library(imgui::imgui ALIAS imgui) target_include_directories(imgui PUBLIC $ $) +# PUBLIC: the define selects the default font loader in imgui.h-level state; +# every TU including imgui.h must agree. +target_compile_definitions(imgui PUBLIC IMGUI_ENABLE_FREETYPE) +target_link_libraries(imgui PUBLIC freetype) target_compile_options(imgui PRIVATE -w) #============================================================================= diff --git a/thirdparty/imgui b/thirdparty/imgui index 6d910d5..9fd429c 160000 --- a/thirdparty/imgui +++ b/thirdparty/imgui @@ -1 +1 @@ -Subproject commit 6d910d5487d11ca567b61c7824b0c78c569d62f0 +Subproject commit 9fd429ca504dfb8aa7257876abe69b74c4e01cae