Skip to content

Commit 6169614

Browse files
sebgodclaude
andcommitted
Fix Equipment tab hit testing with region-based approach, enlarge sidebar
Replace fragile layout-reconstruction hit tests with RegisterClickable: every clickable element registers its bounds during Render, and HitTest walks that list. Single source of truth — no geometry duplication. Also: - Increase sidebar width from 40px to 52px base (larger tap targets) - Fix Create Profile button not clickable (was missing from old hit test) - All slots, device rows, buttons, and text fields now properly clickable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 933bdd1 commit 6169614

File tree

2 files changed

+29
-198
lines changed

2 files changed

+29
-198
lines changed

src/TianWen.UI.Gui/VkEquipmentTab.cs

Lines changed: 28 additions & 197 deletions
Original file line numberDiff line numberDiff line change
@@ -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
// -----------------------------------------------------------------------

src/TianWen.UI.Gui/VkGuiRenderer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public sealed class VkGuiRenderer : IDisposable
2828
public VkEquipmentTab EquipmentTab => _equipmentTab;
2929

3030
// Base layout constants (at 1x scale)
31-
private const float BaseSidebarWidth = 40f;
31+
private const float BaseSidebarWidth = 52f;
3232
private const float BaseStatusBarHeight = 28f;
3333
private const float BaseFontSize = 14f;
3434

0 commit comments

Comments
 (0)