The setup menu picks from drop-downs

Every list group is a combo box now: track, time, weather, length, vehicle,
and colour/badge or team/position. Scenario stays as visible buttons because
it decides what the other lists contain, so it should not be hidden behind
one of them.

This ends a problem I had been solving the wrong way. The menu was flat
lists of everything, which was fine when the content was short enough to see
at once - the quality that made it feel like the pod panel. The promoted
resource file roughly doubled it, and I answered with two columns, then
better margins, then a general column flow, each time keeping an idiom whose
justification had already gone. Eight controls replace ninety-odd rows.

At 800x600 the columns go from 131px to 323px, so nothing ellipsizes any
more - the longest name wants 158. The whole menu now needs 310px of the 492
above the buttons there, and 529 of 900 at 1080p, so adding vehicles or maps
cannot crowd it again.

The boxes are owner-drawn - green on black, highlight inverted rather than
tinted - so they read as part of the panel instead of arriving in system
colours. They are rebuilt rather than moved when the scenario changes, since
it swaps two of them outright and reshuffles the track list.

Built and run at 640x480, 800x600, 1280x720 and 1920x1080.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-07 09:48:10 -05:00
co-authored by Claude Opus 5
parent da7f675cca
commit 3ed6498183
+238 -129
View File
@@ -242,6 +242,13 @@ namespace
HWND menuWindow;
HWND nameEdit;
// Every list group is a drop-down. They are owner-drawn so they
// keep the green-on-black panel look instead of arriving in
// system colours, and their labels are painted by PaintMenu.
HWND combo[GroupCount];
RECT comboLabel[GroupCount];
HFONT textFont;
HFONT titleFont;
HBRUSH editBrush;
@@ -251,6 +258,9 @@ namespace
};
FEState *gFE = NULL;
// ItemName() reads this to answer with the right track list.
const int *gItemNameSelection = NULL;
int gLastMissionSeconds = 0;
// carried across races so cycling back to the menu keeps the
@@ -613,9 +623,54 @@ namespace
// Layout: three columns of lists + the launch button
//---------------------------------------------------------------
//---------------------------------------------------------------
// The groups that became drop-downs, in the order they are laid
// out. Scenario is deliberately NOT one of them: it decides what
// the others contain, so it stays visible as a pair of buttons.
//---------------------------------------------------------------
int ComboGroups(const int *selection, int *groups)
{
int n = 0;
groups[n++] = GroupMap;
groups[n++] = GroupTime;
groups[n++] = GroupWeather;
groups[n++] = GroupLength;
groups[n++] = GroupVehicle;
if (IsFootball(selection))
{
groups[n++] = GroupTeam;
groups[n++] = GroupPosition;
}
else
{
groups[n++] = GroupColor;
groups[n++] = GroupBadge;
}
return n;
}
// The combo id is its group, so CBN_SELCHANGE says which list moved.
const int kComboIdBase = 100;
void DestroyCombos(FEState *fe)
{
for (int g = 0; g < GroupCount; ++g)
{
if (fe->combo[g] != NULL)
{
DestroyWindow(fe->combo[g]);
fe->combo[g] = NULL;
}
}
}
// defined below, next to the catalogs it reads
const char *ItemName(int group, int index);
void LayoutMenu(FEState *fe, int client_w, int client_h)
{
fe->itemCount = 0;
gItemNameSelection = fe->selection;
int row_h = client_h / 36;
if (row_h < 18) row_h = 18;
@@ -624,111 +679,71 @@ namespace
int top = client_h / 7;
//---------------------------------------------------------------
// Fixed columns cannot hold this menu any more. The promoted
// resource file brought eleven vehicles and seven maps, and the
// settings column - scenario, map, time, weather, length - was
// already within 11px of the bottom at 800x600 before any of
// them arrived. So the groups FLOW: they fill a column, then
// start the next, and the layout takes as many columns as the
// content needs. Nothing here knows how many that will be, which
// is the point - adding a map or a vehicle can no longer push
// anything off the screen.
// Every list is a drop-down, so the menu no longer grows with the
// content: eight controls instead of ninety-odd rows. Two columns
// of label-over-box, settings on the left and loadout on the right,
// with the scenario left as visible buttons because it decides what
// the other lists contain.
//---------------------------------------------------------------
int map_count;
ActiveMaps(fe->selection, &map_count);
struct FlowGroup { int group; int count; };
FlowGroup flow[8];
int flow_count = 0;
flow[flow_count].group = GroupScenario;
flow[flow_count++].count = FE_COUNT(kScenarios);
flow[flow_count].group = GroupMap;
flow[flow_count++].count = map_count;
flow[flow_count].group = GroupTime;
flow[flow_count++].count = FE_COUNT(kTimes);
flow[flow_count].group = GroupWeather;
flow[flow_count++].count = FE_COUNT(kWeather);
flow[flow_count].group = GroupLength;
flow[flow_count++].count = FE_COUNT(kLengths);
flow[flow_count].group = GroupVehicle;
flow[flow_count++].count = FE_COUNT(kVehicles);
if (IsFootball(fe->selection))
{
flow[flow_count].group = GroupTeam;
flow[flow_count++].count = FE_COUNT(kTeams);
flow[flow_count].group = GroupPosition;
flow[flow_count++].count = FE_COUNT(kPositions);
}
else
{
flow[flow_count].group = GroupColor;
flow[flow_count++].count = FE_COUNT(kColors);
flow[flow_count].group = GroupBadge;
flow[flow_count++].count = FE_COUNT(kBadges);
}
// Every column starts two rows down so the pilot name box has the
// same home on whichever column ends up last, and they all line
// up. The bottom is reserved for LAUNCH and the lobby buttons.
int content_top = top + 2 * row_h;
int bottom = client_h - (RPL4Lobby_Configured() ? 7 : 4) * row_h;
int rows_per_col = (bottom - content_top) / row_h;
if (rows_per_col < 1) rows_per_col = 1;
// Pass one: how many columns does this content want? A group is
// preceded by a blank row unless it lands at the top of a column,
// and a group longer than a column simply continues into the next.
int columns = 1;
{
int row = 0;
for (int g = 0; g < flow_count; ++g)
{
if (row > 0) ++row;
for (int i = 0; i < flow[g].count; ++i)
{
if (row >= rows_per_col) { ++columns; row = 0; }
++row;
}
}
}
int margin = client_w / 20;
int gap = client_w / 50;
int col_w = (client_w - 2 * margin - (columns - 1) * gap) / columns;
int margin = client_w / 14;
int gap = client_w / 20;
int col_w = (client_w - 2 * margin - gap) / 2;
if (col_w < 1) col_w = 1;
int col2 = margin + col_w + gap;
// Pass two: the same walk, placing items this time.
int last_col = 0;
int block_h = row_h * 5 / 2; // label, box, breathing room
// scenario: buttons, top of the left column
int y = top;
fe->comboLabel[GroupScenario].left = margin;
fe->comboLabel[GroupScenario].right = margin + col_w;
fe->comboLabel[GroupScenario].top = y;
fe->comboLabel[GroupScenario].bottom = y + row_h;
y += row_h;
for (int i = 0; i < FE_COUNT(kScenarios); ++i)
{
int column = 0, row = 0;
for (int g = 0; g < flow_count; ++g)
{
if (row > 0) ++row;
for (int i = 0; i < flow[g].count; ++i)
{
if (row >= rows_per_col) { ++column; row = 0; }
FEItem *item = &fe->items[fe->itemCount++];
item->group = flow[g].group;
item->index = i;
item->rect.left = margin + column * (col_w + gap);
item->rect.top = content_top + row * row_h;
item->rect.right = item->rect.left + col_w;
item->rect.bottom = item->rect.top + row_h;
++row;
}
last_col = column;
}
FEItem *item = &fe->items[fe->itemCount++];
item->group = GroupScenario;
item->index = i;
item->rect.left = margin + i * (col_w / 2);
item->rect.top = y;
item->rect.right = item->rect.left + col_w / 2 - row_h / 3;
item->rect.bottom = y + row_h;
}
y += row_h + row_h / 2;
int groups[GroupCount];
int group_count = ComboGroups(fe->selection, groups);
// left column takes the mission settings, right the loadout; the
// split is where the vehicle list starts.
int left_y = y;
int right_y = top;
for (int g = 0; g < group_count; ++g)
{
int group = groups[g];
Logical left = (group == GroupMap || group == GroupTime ||
group == GroupWeather || group == GroupLength);
int x = left ? margin : col2;
int *slot = left ? &left_y : &right_y;
fe->comboLabel[group].left = x;
fe->comboLabel[group].right = x + col_w;
fe->comboLabel[group].top = *slot;
fe->comboLabel[group].bottom = *slot + row_h;
*slot += block_h;
}
int loadout_col = margin + last_col * (col_w + gap);
// pilot name goes under the loadout column
int name_y = right_y;
// launch button
FEItem *launch = &fe->items[fe->itemCount++];
launch->group = GroupLaunch;
launch->index = 0;
launch->rect.left = loadout_col;
launch->rect.left = col2;
launch->rect.top = client_h - 3 * row_h;
launch->rect.right = loadout_col + col_w;
launch->rect.right = col2 + col_w;
launch->rect.bottom = client_h - row_h;
// Steam lobby buttons, offered whenever environ.ini asked for Steam.
@@ -738,17 +753,17 @@ namespace
FEItem *host = &fe->items[fe->itemCount++];
host->group = GroupSteamHost;
host->index = 0;
host->rect.left = loadout_col;
host->rect.left = col2;
host->rect.top = client_h - 6 * row_h;
host->rect.right = loadout_col + col_w;
host->rect.right = col2 + col_w;
host->rect.bottom = client_h - 5 * row_h;
FEItem *join = &fe->items[fe->itemCount++];
join->group = GroupSteamJoin;
join->index = 0;
join->rect.left = loadout_col;
join->rect.left = col2;
join->rect.top = client_h - (9 * row_h) / 2;
join->rect.right = loadout_col + col_w;
join->rect.right = col2 + col_w;
join->rect.bottom = client_h - (7 * row_h) / 2;
}
@@ -769,11 +784,54 @@ namespace
quit->rect.right = margin + col_w / 2;
quit->rect.bottom = client_h - row_h;
// pilot name edit sits at the top of column 3
// pilot name, under the loadout column
fe->comboLabel[GroupLaunch].left = col2; // reused: name label
fe->comboLabel[GroupLaunch].right = col2 + col_w;
fe->comboLabel[GroupLaunch].top = name_y;
fe->comboLabel[GroupLaunch].bottom = name_y + row_h;
if (fe->nameEdit != NULL)
{
MoveWindow(fe->nameEdit,
loadout_col, top - row_h / 4, col_w, row_h, TRUE);
MoveWindow(fe->nameEdit, col2, name_y + row_h, col_w, row_h, TRUE);
}
//---------------------------------------------------------------
// The drop-downs themselves. Rebuilt rather than moved, because
// the scenario swaps two of them outright (team/position for
// colour/badge) and reshuffles the track list.
//---------------------------------------------------------------
DestroyCombos(fe);
for (int g = 0; g < group_count; ++g)
{
int group = groups[g];
const RECT &label = fe->comboLabel[group];
int count = GroupSize(group, fe->selection);
// The height given at creation is the DROPPED height - what the
// closed box shows is the item height - so ask for enough to
// show a dozen rows without a scrollbar where the list is short.
int drop_rows = (count < 12) ? count : 12;
HWND box = CreateWindowExA(
0, "COMBOBOX", "",
WS_CHILD | WS_VISIBLE | WS_VSCROLL |
CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS,
label.left, label.top + row_h,
label.right - label.left, row_h + drop_rows * row_h,
fe->menuWindow, (HMENU)(INT_PTR)(kComboIdBase + group),
(HINSTANCE) GetWindowLongPtr(fe->menuWindow, GWLP_HINSTANCE), NULL);
if (box == NULL)
{
continue;
}
SendMessageA(box, WM_SETFONT, (WPARAM) fe->textFont, TRUE);
SendMessageA(box, CB_SETITEMHEIGHT, (WPARAM) -1, row_h);
SendMessageA(box, CB_SETITEMHEIGHT, 0, row_h);
for (int i = 0; i < count; ++i)
{
SendMessageA(box, CB_ADDSTRING, 0, (LPARAM) ItemName(group, i));
}
if (fe->selection[group] >= count) fe->selection[group] = 0;
SendMessageA(box, CB_SETCURSEL, fe->selection[group], 0);
fe->combo[group] = box;
}
}
@@ -796,7 +854,6 @@ namespace
}
// the map rows need the active scenario's list
const int *gItemNameSelection = NULL;
const char *ItemName(int group, int index)
{
@@ -858,21 +915,31 @@ namespace
SelectObject(mem, fe->textFont);
// group headers (drawn above each group's first item)
int previous_group = -1;
// Labels: one above each drop-down, one above the scenario
// buttons, one above the pilot name box. The drop-downs draw
// themselves (WM_DRAWITEM), so all that is left here is their
// captions.
SetTextColor(mem, kGreenBright);
for (int g = 0; g < GroupCount; ++g)
{
Logical labelled = (g == GroupScenario) || (fe->combo[g] != NULL);
if (!labelled && g != GroupLaunch)
{
continue;
}
RECT label = fe->comboLabel[g];
if (label.right <= label.left)
{
continue;
}
const char *caption = (g == GroupLaunch) ? "PILOT NAME" : GroupTitle(g);
DrawTextA(mem, caption, -1, &label,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
for (int i = 0; i < fe->itemCount; ++i)
{
const FEItem *item = &fe->items[i];
if (item->group != previous_group && item->group < GroupLaunch)
{
previous_group = item->group;
RECT header = item->rect;
header.top -= (item->rect.bottom - item->rect.top);
header.bottom = item->rect.top;
SetTextColor(mem, kGreenBright);
DrawTextA(mem, GroupTitle(item->group), -1, &header,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
Logical selected =
(item->group >= GroupLaunch) ||
@@ -937,22 +1004,7 @@ namespace
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
}
// pilot-name header
if (fe->nameEdit != NULL)
{
RECT edit_rect;
GetWindowRect(fe->nameEdit, &edit_rect);
POINT corner = { edit_rect.left, edit_rect.top };
ScreenToClient(fe->menuWindow, &corner);
RECT header;
header.left = corner.x;
header.right = corner.x + 300;
header.bottom = corner.y;
header.top = corner.y - 26;
SetTextColor(mem, kGreenBright);
DrawTextA(mem, "PILOT NAME", -1, &header,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
// (the pilot-name caption is drawn with the other labels above)
BitBlt(hdc, 0, 0, client.right, client.bottom, mem, 0, 0, SRCCOPY);
@@ -1039,6 +1091,63 @@ namespace
}
break;
case WM_COMMAND:
if (fe != NULL && HIWORD(wParam) == CBN_SELCHANGE)
{
int group = LOWORD(wParam) - kComboIdBase;
if (group >= 0 && group < GroupCount && fe->combo[group] != NULL)
{
int pick = (int) SendMessageA(fe->combo[group], CB_GETCURSEL, 0, 0);
if (pick >= 0)
{
fe->selection[group] = pick;
}
InvalidateRect(fe->menuWindow, NULL, FALSE);
return 0;
}
}
break;
//
// The drop-downs are owner-drawn so they read as part of the
// panel rather than as system widgets: green on black, and the
// highlight inverted rather than tinted.
//
case WM_DRAWITEM:
if (fe != NULL)
{
DRAWITEMSTRUCT *di = (DRAWITEMSTRUCT *) lParam;
if (di->CtlType == ODT_COMBOBOX && (int) di->itemID >= 0)
{
Logical hot =
((di->itemState & (ODS_SELECTED | ODS_COMBOBOXEDIT)) == ODS_SELECTED);
HBRUSH back = CreateSolidBrush(hot ? kGreenDim : kBlack);
FillRect(di->hDC, &di->rcItem, back);
DeleteObject(back);
char text[128];
text[0] = '\0';
SendMessageA(di->hwndItem, CB_GETLBTEXT, di->itemID, (LPARAM) text);
RECT label = di->rcItem;
label.left += 6;
SetBkMode(di->hDC, TRANSPARENT);
SetTextColor(di->hDC, hot ? kBlack : kGreenBright);
DrawTextA(di->hDC, text, -1, &label,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
return TRUE;
}
}
break;
case WM_CTLCOLORLISTBOX:
if (fe != NULL)
{
SetTextColor((HDC) wParam, kGreenBright);
SetBkColor((HDC) wParam, kBlack);
return (LRESULT) fe->editBrush;
}
break;
case WM_CTLCOLOREDIT:
if (fe != NULL)
{