@@ -33,6 +33,7 @@ public record EquipmentHitResult(EquipmentHitType Type, AssignTarget? Slot = nul
3333 public sealed class VkEquipmentTab
3434 {
3535 private readonly VkRenderer _renderer ;
36+ private readonly List < ( float X , float Y , float W , float H , EquipmentHitResult Result ) > _clickableRegions = [ ] ;
3637
3738 // Layout constants (at 1x scale)
3839 private const float BaseProfilePanelWidth = 360f ;
@@ -70,6 +71,14 @@ public sealed class VkEquipmentTab
7071 /// <summary>Tab state (scroll offsets, discovery results, assignment mode).</summary>
7172 public EquipmentTabState State { get ; } = new EquipmentTabState ( ) ;
7273
74+ /// <summary>
75+ /// Registers a clickable region during rendering. Hit testing walks this list.
76+ /// </summary>
77+ private void RegisterClickable ( float x , float y , float w , float h , EquipmentHitResult result )
78+ {
79+ _clickableRegions . Add ( ( x , y , w , h , result ) ) ;
80+ }
81+
7382 /// <summary>Frame counter for text input cursor blink.</summary>
7483 public long FrameCount { get ; set ; }
7584
@@ -91,6 +100,9 @@ public void Render(
91100 float dpiScale ,
92101 string fontPath )
93102 {
103+ // Clear clickable regions from previous frame
104+ _clickableRegions . Clear ( ) ;
105+
94106 // Clear the whole content area first
95107 FillRect ( left , top , width , height , ContentBg ) ;
96108
@@ -109,46 +121,6 @@ public void Render(
109121 RenderProfileView ( appState , left , top , width , height , dpiScale , fontPath ) ;
110122 }
111123
112- /// <summary>
113- /// Hit-tests the equipment tab for a mouse click.
114- /// </summary>
115- public EquipmentHitResult ? HitTest ( float x , float y , float contentLeft , float contentTop , float dpiScale )
116- {
117- // We need the active profile to determine which branch was rendered.
118- // Reconstruct the same layout geometry as Render.
119- var padding = BasePadding * dpiScale ;
120- var bottomBarH = BaseBottomBarHeight * dpiScale ;
121- var buttonH = BaseButtonHeight * dpiScale ;
122- var itemH = BaseItemHeight * dpiScale ;
123- var headerH = BaseHeaderHeight * dpiScale ;
124-
125- // If in "no profile" view
126- if ( State . IsCreatingProfile )
127- {
128- return HitTestProfileCreation ( x , y , contentLeft , contentTop , dpiScale ) ;
129- }
130-
131- // Profile view
132- var profilePanelW = BaseProfilePanelWidth * dpiScale ;
133- var deviceListLeft = contentLeft + profilePanelW ;
134-
135- // Text input hit is not needed here (no text input in profile view)
136-
137- // Left panel hit tests
138- if ( x >= contentLeft && x < deviceListLeft )
139- {
140- return HitTestProfilePanel ( x , y , contentLeft , contentTop , dpiScale ) ;
141- }
142-
143- // Right panel hit tests
144- if ( x >= deviceListLeft )
145- {
146- return HitTestDeviceList ( x , y , deviceListLeft , contentTop , dpiScale ) ;
147- }
148-
149- return null ;
150- }
151-
152124 // -----------------------------------------------------------------------
153125 // No-profile view
154126 // -----------------------------------------------------------------------
@@ -175,6 +147,7 @@ private void RenderNoProfile(
175147 var btnY = centerY + padding ;
176148
177149 FillRect ( btnX , btnY , buttonW , buttonH , CreateButton ) ;
150+ RegisterClickable ( btnX , btnY , buttonW , buttonH , new EquipmentHitResult ( EquipmentHitType . CreateButton ) ) ;
178151 DrawText (
179152 "Create Profile" . AsSpan ( ) ,
180153 fontPath ,
@@ -220,11 +193,13 @@ private void RenderProfileCreation(
220193 State . ProfileNameInput ,
221194 ( int ) fieldX , inputY , ( int ) fieldW , ( int ) fieldH ,
222195 fontPath , fontSize , FrameCount ) ;
196+ RegisterClickable ( fieldX , inputY , fieldW , fieldH , new EquipmentHitResult ( EquipmentHitType . TextInput ) ) ;
223197
224198 // Create button
225199 var btnY = inputY + ( int ) fieldH + ( int ) padding ;
226200 var btnW = 120f * dpiScale ;
227201 FillRect ( fieldX , btnY , btnW , buttonH , CreateButton ) ;
202+ RegisterClickable ( fieldX , btnY , btnW , buttonH , new EquipmentHitResult ( EquipmentHitType . CreateButton ) ) ;
228203 DrawText (
229204 "Create" . AsSpan ( ) ,
230205 fontPath ,
@@ -389,6 +364,7 @@ private void RenderProfilePanel(
389364 if ( addOtaBtnY + buttonH < y + h - padding )
390365 {
391366 FillRect ( x + padding , addOtaBtnY , addOtaBtnW , buttonH , CreateButton ) ;
367+ RegisterClickable ( x + padding , addOtaBtnY , addOtaBtnW , buttonH , new EquipmentHitResult ( EquipmentHitType . AddOtaButton ) ) ;
392368 DrawText (
393369 "+ Add OTA" . AsSpan ( ) ,
394370 fontPath ,
@@ -421,6 +397,7 @@ private float RenderProfileSlot(
421397 var bgColor = isActive ? SlotActive : SlotNormal ;
422398
423399 FillRect ( x , y , w , itemH , bgColor ) ;
400+ RegisterClickable ( x , y , w , itemH , new EquipmentHitResult ( EquipmentHitType . ProfileSlot , slot ) ) ;
424401
425402 // Separator line at bottom of slot
426403 FillRect ( x , y + itemH - 1f , w , 1f , SeparatorColor ) ;
@@ -503,6 +480,7 @@ private void RenderDeviceList(
503480
504481 // Row background
505482 FillRect ( x , rowY , w , itemH , DeviceRowBg ) ;
483+ RegisterClickable ( x , rowY , w , itemH , new EquipmentHitResult ( EquipmentHitType . DeviceRow , DeviceIndex : i ) ) ;
506484 FillRect ( x , rowY + itemH - 1f , w , 1f , SeparatorColor ) ;
507485
508486 // Type badge
@@ -543,6 +521,7 @@ private void RenderDeviceList(
543521 var discoverLabel = State . IsDiscovering ? "Discovering..." : "Discover" ;
544522
545523 FillRect ( discoverBtnX , discoverBtnY , discoverBtnW , buttonH , CreateButton ) ;
524+ RegisterClickable ( discoverBtnX , discoverBtnY , discoverBtnW , buttonH , new EquipmentHitResult ( EquipmentHitType . DiscoverButton ) ) ;
546525 DrawText (
547526 discoverLabel . AsSpan ( ) ,
548527 fontPath ,
@@ -590,174 +569,26 @@ private void RenderBottomBar(
590569 }
591570
592571 // -----------------------------------------------------------------------
593- // Hit testing helpers
572+ // Hit testing
594573 // -----------------------------------------------------------------------
595574
596- private EquipmentHitResult ? HitTestProfileCreation (
597- float x , float y ,
598- float left , float top ,
599- float dpiScale )
600- {
601- var padding = BasePadding * dpiScale ;
602- var fontSize = BaseFontSize * dpiScale ;
603- var headerH = BaseHeaderHeight * dpiScale ;
604- var fieldH = ( int ) ( BaseItemHeight * dpiScale * 1.4f ) ;
605- var buttonH = BaseButtonHeight * dpiScale ;
606-
607- var fieldY = ( int ) ( top + padding + headerH + padding + fontSize * 1.6f ) ;
608- var fieldX = ( int ) ( left + padding ) ;
609- var fieldW = ( int ) Math . Min ( 360f * dpiScale , 9999f ) ;
610-
611- // Text input
612- if ( TextInputRenderer . HitTest ( ( int ) x , ( int ) y , fieldX , fieldY , fieldW , fieldH ) )
613- {
614- return new EquipmentHitResult ( EquipmentHitType . TextInput ) ;
615- }
616-
617- // Create button
618- var btnY = fieldY + fieldH + ( int ) padding ;
619- var btnW = ( int ) ( 120f * dpiScale ) ;
620- if ( x >= fieldX && x < fieldX + btnW && y >= btnY && y < btnY + buttonH )
621- {
622- return new EquipmentHitResult ( EquipmentHitType . CreateButton ) ;
623- }
624-
625- return null ;
626- }
627-
628- private EquipmentHitResult ? HitTestProfilePanel (
629- float x , float y ,
630- float left , float top ,
631- float dpiScale )
632- {
633- var padding = BasePadding * dpiScale ;
634- var itemH = BaseItemHeight * dpiScale ;
635- var headerH = BaseHeaderHeight * dpiScale ;
636- var buttonH = BaseButtonHeight * dpiScale ;
637- var w = BaseProfilePanelWidth * dpiScale ;
638-
639- var cursor = top + padding + headerH + padding ; // after separator
640-
641- // Mount slot
642- if ( HitInRow ( y , cursor , itemH ) )
643- {
644- return new EquipmentHitResult ( EquipmentHitType . ProfileSlot , new AssignTarget . ProfileLevel ( "Mount" ) ) ;
645- }
646- cursor += itemH ;
647-
648- // Site info row (non-clickable — skip it if visible)
649- // We don't know at hit-test time whether site was rendered; always skip one row
650- cursor += itemH ;
651- cursor += padding / 2f ;
652-
653- // Guider
654- if ( HitInRow ( y , cursor , itemH ) )
655- {
656- return new EquipmentHitResult ( EquipmentHitType . ProfileSlot , new AssignTarget . ProfileLevel ( "Guider" ) ) ;
657- }
658- cursor += itemH ;
659-
660- // GuiderCamera
661- if ( HitInRow ( y , cursor , itemH ) )
662- {
663- return new EquipmentHitResult ( EquipmentHitType . ProfileSlot , new AssignTarget . ProfileLevel ( "GuiderCamera" ) ) ;
664- }
665- cursor += itemH ;
666-
667- // GuiderFocuser
668- if ( HitInRow ( y , cursor , itemH ) )
669- {
670- return new EquipmentHitResult ( EquipmentHitType . ProfileSlot , new AssignTarget . ProfileLevel ( "GuiderFocuser" ) ) ;
671- }
672- cursor += itemH + padding + padding ;
673-
674- // OTA sections — we cannot know the exact count without state, so we use State
675- var data = State . AllProfiles . Count > 0 ? null : ( ProfileData ? ) null ;
676- // Use dummy index traversal over the visible profile's OTAs
677- // (caller passes appState; we use State.AllProfiles — but hit-test is called
678- // before we have appState. We walk OTA slots generically up to some maximum.)
679- const int MaxOtaHitTest = 8 ;
680- for ( var i = 0 ; i < MaxOtaHitTest ; i ++ )
681- {
682- // OTA header row
683- cursor += itemH ;
684-
685- // Camera
686- if ( HitInRow ( y , cursor , itemH ) )
687- {
688- return new EquipmentHitResult ( EquipmentHitType . ProfileSlot , new AssignTarget . OTALevel ( i , "Camera" ) ) ;
689- }
690- cursor += itemH ;
691-
692- // Focuser
693- if ( HitInRow ( y , cursor , itemH ) )
694- {
695- return new EquipmentHitResult ( EquipmentHitType . ProfileSlot , new AssignTarget . OTALevel ( i , "Focuser" ) ) ;
696- }
697- cursor += itemH ;
698-
699- // FilterWheel
700- if ( HitInRow ( y , cursor , itemH ) )
701- {
702- return new EquipmentHitResult ( EquipmentHitType . ProfileSlot , new AssignTarget . OTALevel ( i , "FilterWheel" ) ) ;
703- }
704- cursor += itemH ;
705-
706- // Cover
707- if ( HitInRow ( y , cursor , itemH ) )
708- {
709- return new EquipmentHitResult ( EquipmentHitType . ProfileSlot , new AssignTarget . OTALevel ( i , "Cover" ) ) ;
710- }
711- cursor += itemH + padding / 2f ;
712- }
713-
714- // [+ Add OTA] button
715- var addOtaBtnW = 120f * dpiScale ;
716- if ( y >= cursor && y < cursor + buttonH && x >= left + padding && x < left + padding + addOtaBtnW )
717- {
718- return new EquipmentHitResult ( EquipmentHitType . AddOtaButton ) ;
719- }
720-
721- return null ;
722- }
723-
724- private EquipmentHitResult ? HitTestDeviceList (
725- float x , float y ,
726- float listLeft , float top ,
727- float dpiScale )
575+ /// <summary>
576+ /// Hit-tests the Equipment tab content area using regions registered during the last Render call.
577+ /// </summary>
578+ public EquipmentHitResult ? HitTest ( float x , float y , float contentLeft , float contentTop , float dpiScale )
728579 {
729- var padding = BasePadding * dpiScale ;
730- var itemH = BaseItemHeight * dpiScale ;
731- var headerH = BaseHeaderHeight * dpiScale ;
732- var buttonH = BaseButtonHeight * dpiScale ;
733-
734- var listTop = top + headerH + padding / 2f ;
735-
736- var devices = State . DiscoveredDevices ;
737- for ( var i = State . DeviceScrollOffset ; i < devices . Count ; i ++ )
580+ // Walk registered clickable regions from last render — single source of truth
581+ foreach ( var ( rx , ry , rw , rh , result ) in _clickableRegions )
738582 {
739- var rowY = listTop + ( i - State . DeviceScrollOffset ) * itemH ;
740- if ( y >= rowY && y < rowY + itemH )
583+ if ( x >= rx && x < rx + rw && y >= ry && y < ry + rh )
741584 {
742- return new EquipmentHitResult ( EquipmentHitType . DeviceRow , DeviceIndex : i ) ;
585+ return result ;
743586 }
744587 }
745588
746- // [Discover] button — approximate bottom position
747- var discoverBtnX = listLeft + padding ;
748- var discoverBtnW = 100f * dpiScale ;
749- // We don't know h here — just check a large enough area
750- if ( x >= discoverBtnX && x < discoverBtnX + discoverBtnW )
751- {
752- return new EquipmentHitResult ( EquipmentHitType . DiscoverButton ) ;
753- }
754-
755589 return null ;
756590 }
757591
758- private static bool HitInRow ( float y , float rowY , float rowH ) =>
759- y >= rowY && y < rowY + rowH ;
760-
761592 // -----------------------------------------------------------------------
762593 // Badge helpers
763594 // -----------------------------------------------------------------------
0 commit comments