UI: settings menu restyle, physical-px text pipeline, menu blur hooks

This commit is contained in:
mchughalex 2026-07-17 03:31:14 +01:00
parent c807e433a7
commit 6c8e885bce
12 changed files with 773 additions and 215 deletions

View file

@ -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

View file

@ -13,5 +13,6 @@ namespace rex::ui {
const char* GetInterRegularCompressedBase85();
const char* GetInterSemiBoldCompressedBase85();
const char* GetInterBoldCompressedBase85();
} // namespace rex::ui

View file

@ -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<ImGuiDialog*> dialogs_;

View file

@ -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;
};

View file

@ -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;
});

View file

@ -55,6 +55,9 @@ std::atomic<void*> 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<bool> g_native_output_active{false};
std::atomic<NativeGuestOutputPostProcessor> g_post_processor{nullptr};
std::atomic<void*> g_post_processor_user_data{nullptr};
std::atomic<bool> 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);
}

View file

@ -37,6 +37,7 @@
#include <rex/logging.h>
#include <rex/math.h>
#include <rex/platform.h>
#include <rex/graphics/gpu_clock_telemetry.h>
#include <rex/graphics/native_guest_renderer.h>
#include <rex/graphics/util/draw.h>
#include <rex/graphics/flags.h>
@ -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.

View file

@ -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<Bh&o@H%kP]4GIVX-TT$=(9u0j(tU^C-W8Bk0<_[FHL[Pg>@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<^Od<C.$Uf[d#P,l<M*I[/twYFw^ADN1p^?trmY3IpKGAPn(KG>c4"
"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@-77ggL<Ah._uwTMg$;OMg&ILrZejESR+xHi08####U(kpuj&krLtPQ)M3)oK%8f''#^;vof1%>Q1M>?>>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;<mNx:ZM3%l].%>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)X<EhgaLd2ubX=rw4d(^xmPIqZNlIqbkrEI/Jg;%xGuG-dq6a."
">oPN#f@;.Mru@u-;Wd*N%J4l$9g)bNRhL:#[PoQaRhUSSa;vof5ODx9a2ZSf&q-T*^<vr6Yp?JN_.2>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<jZEe-V<T%aG###"
"$DT/)Cx]C4KgSX-`(wCu^1dUp1:49/sTnV#%^qV$@?'(GM^^&TF@_f#J%g0M=I*^-X8:-m%$ee6T.^C4:G*^-0,mb%'7ZV-D1NT/9%`v#U]p=G6EjV*ENPO01xXV60#nK)w@YXZ6MPxF"
"LvS])*c([,:]OwB-@X?o]RmDD$H8S.WCWfGR5b%kI=u@t;#w#A/Pt7%(*Yg.[PUV$M&w]/nE9D3^hlG*o&,a4Y_:ENZ[7J3TG*A+_N$r32tWV7YMnr?Pg#j'hMbv3x$v%5,7%AtIi:%="
"CfVSKHua(3W#)#5D1.G4JwqUdPG4Z$#cDE-V:X3.5p4)O.[9N(UtqbiMBm=,U&VL1C>+/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%$-_+6ufT<i4&sx=#j:^l87P>p.^,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:@YnL8D8<Ol`4_w94;elNVVHB9Lu*NuqBDK.#JV0o<$O,qv7A2b(NL>i8.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_,)a0<j9#j'B#iiOjL%hp:dqikImp?^,>K>fv."
"E]*Cl>8J8F./aLu*+n1GTr%A#Vf0xt:Xwj7FhsMZcpfp:nD)s$**Y5J6'N6*;P^WUD-g&63vN3LsmW/uMG[4OUqbnS:=,G>'lFU?k33<JLFjaE-w^Y#<_kLpd1TMg[Clr-h:Y:/N6fX-"
"Df*F3twC.3u7XI)CDLQ9Px%?5GBxVK?]MZu3.St%dfn^AcbKaIl1h]J[Z;>KNaY>@1vSMg'Ld9A9T9>#GWM:vAQAhRmXv)Td/2RE1ib)QogYqUdfvYutZYC.Hoh;HOcF'#vdu'v(o9F$"
":aaj$V,.&4(n%J3v<'Y$C></%$,F.3FtJs$G3:JYp-CqrJ4C5&?M%E)2bC#K5<',Gn'[=-l]^XQ,WOiZ,Ms.dmaNSC%:wSawSrt[D+>>#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*ffCD<u.OJi,DLq;v0CG)A)s]+"
"X;@D*9<hc)4ctM(9,B+4HjE.3C4Lp7h:Ha3j`$No.EsRcKkXBHhhk'4mj:A$A9&`N1Vqr>Rk;r%Vhgp7U_%##vZYbuUWGJ$]FX&#DeG@.auvs.BFEp.2=Ss$`fD?[gSA+4pZ'u$<3ie&"
"H+;>Gqwpo.WP:@GIjJR*.'d[Kf1E^f#foQ<m[QP/[Yx/H^-()IagKi9X@a32rFT@fC'irqp8#JLUC^K`,*;gd6=JPYiY9^_IDF5#O'[0#@Hnv9,SbA#jJ7Z-mQ;wg:#)C&]9^:/pbv>#"
"O-'U#JeL(K#kK.hsxm#J$NY4JZX-i^YueUcEf,8R(sL,vnnWrL/Lo0%pd''#q####GZKEq?M#F@RXXM$/6>##)LEn<W:3/1$9LCW#,Y:vs6YY#T@W+N)id##<f>3#jkl%lUSM>ZA<mR*"
"U*DS73+G&#oE6^#/(Au-(Y2^Mv,@tLgTOrNb_d##'fZt.HqpOf'>)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;IL<wh6mi6FveLpd2QuK-aHACo,Gu"
"4GcU=SeMHuEnI3%3+r`EGr0hP4ctM(UAaE-b4VA0s5G,2(1U`gcWX5h[OF5AB-H'8`O04LM>ZPJce?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-<E6^Rb<kBMOTg8%u;C-*E4$##(16g)ZVQQ/`r29/<Jo-)bU79."
"/<XQ'V>$6/#P]Q<s;<g9g_k]uD0uF`ZP66/=7OBuBSwZ9jM])=fCg]O<P=B#1S4Q'OlN<9nDP)4;/lRE/qjhuf.-+<,iUZRaHs?##k)K93l'^u[mFS1BopvR_5H6u+Gkc%'Wi.LC;S>,"
"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/<w^<@ke>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%foR<Q]Xo`bcB6n]%vNVjK,Ndid##Gqr4#$a#S*)HAGDu.OJiD2;qVj>ou,*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@#<COUI5*YOoId.%tN&)n#?O;9d*e(gq%/5##"
".W+41b'F9#HXI%#ihtM(T8gC%Gt@X-qVqF,Pl4jL?FKL]fU'6s+L8oeq]%JN/P8Ok=PeLp@D(aL>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<wZj%=94:,DmE$#,F7$##T@S_#ND#G4nTjKYge*[T1RHZ$KsHd)6,r29>)PnH+s&HI2?3]uZ21]7t/u?#LU(j]R&@&@mLF5tN@TOS?S]`'"
"[,pO:Fg8iBj8FL3I0pZY]iclSF#[<L,.>>#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%3u<bdb(5"
"<Pr3#.^@)vw6@W$kh1$#?PUV$epq0(#&_e$VBq,3Ke4`#*Trc)SZw4]$/@uc$i+&+%8su5#<5rmbAmPO>f-W)xKv(EqLlXR$h<lS&w7]Fxc3dr&l:ZuWv7tuD_Kk.-6YY#m`tV$;c?X-"
"nDvJ(7e;suRKY:ZE4PMglj9>ck2-:nc]sDFmVPMTqIs&,66@+49j/_%8*RalnOAx$h6eX-r6B/(3cq<C:b=%k$%rvU'w7dXmYsrHTJ)-C>KC*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<EI^&=iL0+*Z>`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<--<uc%/lq$^"
"_=;n_=m7d#dj1uuNvXG@KQwBon63^Nogf`Qb&lXu]E?fQtv'2PD9V^J3<`#K2.q-JAU'u'#kQHJsh?RD*7c;_iN*eDV$Q5aNms>B>x1wBG$$/Uc6sfFcTocGI?5qVe0IJh1Qwu,#[]/."
"^o,98:E0j(1T,t-^P;;?)^:E4.S39/O/WN;fG,wJ4FVx'0FStHa7gE&vlNpMGTD4taWisXbn?+r*:X>9p0?R:/$O8**5am<cW^%kLmg5je6wANrG>bY+E$st(53o#&%Qs:utKq9Hon=T"
"x^j%=kpBv>0g88.5cNY5xaaD<?;u`4&^`H2T5xF4Pm(u$0/vS/C[@@#bMKG.nin]4o0KT%mLx[#Zx-lIqFrc=/]&EIJSKC@cOjY-0TZck>#X/=K+U=lx-C#IpWPn<sG)X8TfL.*ItdoM"
"s2a*YXJ2ACAMKQDOil`u[q1X:JmXbuYGpA6DqLP8B/?)6K0h%bS=%M3)Kq$7e^]Y#f/mLp_&NMgF47n'9[ww#mr0s-lf>d$ubXI)euc@#Vo`7<1=71<qO%Gi$>q7%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<kFCn4v*]<A>/"
"M*`0<u&]DQT3^v]`)di6Gw5l*7&-7aYAs1Bu-3j0ItkwKgCGv$r:;T.ke#Q'MNuw$=>_)4V,jhLXU>hL64&Gr@kGq8(f,7:hW=;-[h['M@J(905#GSR^uoA#g]0b%luo=Y&#2Abg;-/("
"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:.3U<O^GDg4dWxOm8mDpHmP?uiC8pWG<5LA+4k_Y)4.r@*d*KmrutV1mJA#t?MJ)uhuw@tKPNl`$'G]R_#=U^:/:L^_-q(e'&kcMD3"
"vl@d)^=9mA9[Tp7@]WF3Q9V=^cLVJFq%rF?k;epF3MEjCqKE_.<;J4ogGFXMRI4F$I[ufCeai6W3hHcC_Qh1Di(?(+DR,iihpl'SSS#lL,XOjLf7W.3hmxD4Kh*iMpCZ)4Mx]fL:;_F*"
"FrF,M910[#3_%^.[$VTC;tnNF^,oTB+>UZBqhrguQ]FkERV;a-0_pE@H,&rC2sS?0S4/I#&DFJCDh6[KR/5##1_<A%_:78#+o.nLcQqV.XYIh(I.X@$MH+<-eDl<'q&A+4:R=,%]h`S."
"xsGJ(1YJ,3X5Du$nHdO(%'7ftf2TI$lM*pu8s+V#6ouhiGtxdL%D6^BGtJ^+,8pna>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@_<kuIv7lu1EFS3rU$<7::?8.<JT,IUb'kkof###pPNMg^7pu,W@T/)(Vg/)5RRLM/@F%$8VE%$ZajJMuwSfLxYx4]vcVn6:pHg/j8$K/GePW-))1I$"
"s^NsejZ<Wec9`G#SS#ci$lKxO(7LV#dE0(&hp>.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;<eOhM]89DAWfJQVIGHW>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&#<Hu%N"
"*_rc)$[IP/BJAeHb-vE7HX(4+cSWD*E*a]FK/l?-B[125fN>/$;]j)#Mrgo.GKkA#eNvru&FgM#K<fJ0n*'Gi$mt10pjYKj3`?/$b#Y?-*3:w-'U2uLUetD#V;>lN#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?#_<O]u"
"`5YY#%BGrm@UW`a[@%T.qH7g),UaJ1$'h:ud^f4nS]>]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&qr<V8>5`-#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+cw0ouO5TV<nSMBN=%66I%n#xu>J4"
"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:A<fN7`%/5##[l*x#?cK:#C5c;-Y1h-'i%EK;$C3D#a.t'B@)fWB';XmV$0?&@U#q`?jUSfL6%A6MAr%qL)VoS#7c.u-)>woL"
"9fX`#]1r_$004&#O,>>#YBB9.vBo8%Y<AH3AS'f)_Tnj<GNS,;#?Mj9]N_KYA1n`H_/ua>PpE8f18TSFW;eiLu$T4*Zv4-=*F6]KG>jO3vAGo/EiRr/iiw,6xjI7emTgKW#%oV'#BQa3"
"](/(#6rN88It]]Y7Ma3k-nB*<RI=W6gs^kbCnpXNDnWOE2+wsBXq(tB.Wh4FXJ,kbBF=%M8,D:_mD>.PmJPIPtE3D<lL,m8_rQM'U.tM(1ZClMA0vM(-a[m><JJGHRbl=uNpMo7,>67N"
"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#Rpjf<omVHkPcIlcOlM4=7SrlJLj@J1=3ae37Vu,Q_kXnB$IRY%P#Ia.QM-,$,o,D-V5f^."
"d1NT/BST@%76jgLfE&J3g<nX]-6r$%nLA'[*x`W#C,'K<:?SOE*h4'6I2N+C=O<3c+iaGH[WcYGK:5%.x_sp/5G&r/ju-w-]UkDP/;QA%7.:Q;tJe)#>r'/#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<Q1HFwqXP2*i-7-RD#Z6QG)KKVK2&wC$Hk21@/PK`'7(rC>#gn#`s%Bg3=qL$##"
"?3`b*Y<Nv6+GMU%E*ZI)^RwO=7?9PfsV%L2:xUA?vcGg3lWhE#uwi+PG*R0?Cup-&gES3)otA60OL,80h`:?-FoN`P-li>&RabqEY05##16`P-9D,n%#ufi':Ne+4FF+F3>HF:.omx9."
"36HF#+BiWBQVw8[cRXJ1c'44G#ZTS7P,B$#tQW`<AxMAbxSOP/V1>_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<I3^h$9[io$j*m<-jxgS/n;2PAd=)/1_4B,j$,>>#/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/<Q/"
"XADD3,5vS/h-2xt-QnV#xb(uP$81?#*%DO%%15##7Dfl#`JJ5#+e#T/XDvs:-e)4;q^d/;jbH030h2@$&@B;:k@F99Gr_BH4,x9)Q,lmLZ$],8wjp/)lur'4^?,T%uQ;a4SvvA4ig'u$"
"8HuT2USKX@uDR5A$jCgu7f[Y-j8XF1#K9i<Teh+MD`Op%Rvvfq>*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$OkEWSA<a/:da<T&+]&gLX`02^PIv-$$EJ)=$=J_&2(q#$ltaX$F9X7/L<<h#ClWrL*%Vp.HKxi0@Wp_&SvG_&Co$.$rbUXM"
"H,qE%JLsH$]?(.$?]f-6^NQ-HHZN)=pNo92mXr(<GZ)DN(RKG)KkwE7/7ZAO#i*R/iNvu,lQvu,YO(Q0-@3F%r1GtRP/;s?vE58eb^$W6&aEwgd;XY,Muw4AAQ$5ALs_o@K$*AFawTiB"
"LG8JC_Ydw'1cx4Apmw+;Xf'F.2_dZ$,u:c#Zmh=MsI@5JM=m-$Z)Y;$RMp-$bol-$*vp-$r2U`a5Ko-$*^FW-<Rwh2Wc<a*tlw]+;PX%tvm@M^2wS_&cQ:7#_0###[gj-$mJ[8##)v5N"
"V/H6#4IeqLG)e+#;QDmLRgH+#^@FV.%8A5#A?pV-nkxQE&i0^#NT,hL(7voLE,W_$G*uoLE,W_$fP1xLAF@&M9lL%M+mx#Mu#axL1;Y$M-#5$MaUTvL(L3/iK?*##QG+29A8)GV+wu1B"
"#ZVxXG>cuGkCsx4T>/58L/O9iDs%5A?gJ]F6I#2BM3_lAC&p=GM)P`<B_-p95kr+;Ng8G;2tN`<>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#(<L/#q;w0#GYN1#doF6#tE24#[K;4#YWM4#u,85#D#*#08(02#-Yu##bEYf%rCcf(JgCG)RM<A+Zrsx+_4TY,k9IP/ud*20#'bi0"
"+dYc232;D38S7A4BT8SRe,f1^Nph7eDX,87REDP8[/=J:dSt+;nLM]=xw.>>&:fu>2?ZlA<j;MBA58JCK(L`ETO-AFZ$EYGBHWrQdmi4](WMcMhp=SI+a#8Iu7?c`<b'edI]r6#+^+6#"
"SOn8#9Qo5#NVL7#VC+.#1e`4#[J:7#4'/5#]%.8#tSE1#,AP##,5>##,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

View file

@ -29,6 +29,7 @@
#include <rex/ui/windowed_app_context.h>
#include <imgui.h>
#include <misc/freetype/imgui_freetype.h>
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<std::string>& 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) {

View file

@ -20,6 +20,7 @@
#include <cstring>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include <imgui.h>
@ -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<std::string_view> 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<RowSpec>& 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<RowSpec>& 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<RowSpec>& 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<RowSpec>& 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<RowSpec>& 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<RowSpec>& 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<int>(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<int>(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);
// 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), 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);
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<int>(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<int>(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,
// 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);
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;
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<int>(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<int>(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<LegendGlyph> 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;
}
}

View file

@ -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
$<BUILD_INTERFACE:${IMGUI_DIR}>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>)
# 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)
#=============================================================================

2
thirdparty/imgui vendored

@ -1 +1 @@
Subproject commit 6d910d5487d11ca567b61c7824b0c78c569d62f0
Subproject commit 9fd429ca504dfb8aa7257876abe69b74c4e01cae