Skip to content

Commit bf4c6f5

Browse files
committed
Add scrolling on-screen menu rails
1 parent 32c6cda commit bf4c6f5

17 files changed

Lines changed: 401 additions & 111 deletions

File tree

android/app/src/main/java/eu/rekawek/coffeegb/android/MainActivity.java

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@
5252
/** Canvas-menu and native external-surface client for {@link EmulationService}. */
5353
public final class MainActivity extends Activity implements RuntimeObserver {
5454

55-
private static final int STATE_MENU_MIN_SLOT = 0;
56-
private static final int STATE_MENU_MAX_SLOT = 3;
55+
private static final int STATE_MENU_MIN_SLOT = StateRef.MIN_SLOT;
56+
private static final int STATE_MENU_MAX_SLOT = StateRef.MAX_SLOT;
5757

5858
private static final int OPEN_ROM_REQUEST = 1;
5959
private static final int IMPORT_BATTERY_REQUEST = 2;
@@ -1805,12 +1805,7 @@ private MenuPreview statePreview(String preferredFocus) {
18051805
}
18061806

18071807
private MenuPageSpec statePage(MenuPreview preview, String preferredFocus) {
1808-
ArrayList<MenuPageSpec.Item> items = new ArrayList<>();
1809-
for (int index = STATE_MENU_MIN_SLOT; index <= STATE_MENU_MAX_SLOT; index++) {
1810-
// Every stable slot remains focusable in both modes. LOAD treats an empty or
1811-
// unavailable slot as a no-op; SAVE overwrites directly without confirmation.
1812-
items.add(new MenuPageSpec.Item("slot:" + index, "SLOT " + index, "", true));
1813-
}
1808+
List<MenuPageSpec.Item> items = stateMenuItems(stateSlots);
18141809
String mode = stateMenuMode == StateMenuMode.SAVE ? "SAVE" : "LOAD";
18151810
return new MenuPageSpec(MenuRoute.SAVE_STATES, "COFFEE GB", mode + " STATES", "", "",
18161811
stateSavedAtLines(stateSlot(preferredFocus == null
@@ -1820,6 +1815,30 @@ private MenuPageSpec statePage(MenuPreview preview, String preferredFocus) {
18201815
preferredFocus == null ? "slot:" + StateRef.MIN_SLOT : preferredFocus, preview);
18211816
}
18221817

1818+
/** Stable state rows shared by SAVE and LOAD; persisted rows carry a visual-only seal hint. */
1819+
static List<MenuPageSpec.Item> stateMenuItems(List<AndroidStateSlot> catalog) {
1820+
ArrayList<MenuPageSpec.Item> items = new ArrayList<>();
1821+
for (int index = STATE_MENU_MIN_SLOT; index <= STATE_MENU_MAX_SLOT; index++) {
1822+
// Every stable slot remains focusable in both modes. LOAD treats an empty or
1823+
// unavailable slot as a no-op; SAVE overwrites directly without confirmation.
1824+
boolean used = catalogContainsLoadableSlot(catalog, index);
1825+
// The compositor uses this semantic marker to distinguish a persisted state from an
1826+
// empty but still focusable slot. It is intentionally not exposed as row copy.
1827+
items.add(new MenuPageSpec.Item("slot:" + index, "SLOT " + index,
1828+
used ? "USED" : "", true));
1829+
}
1830+
return List.copyOf(items);
1831+
}
1832+
1833+
private static boolean catalogContainsLoadableSlot(List<AndroidStateSlot> catalog, int index) {
1834+
for (AndroidStateSlot slot : catalog) {
1835+
if (slot.index() == index && slot.loadable()) {
1836+
return true;
1837+
}
1838+
}
1839+
return false;
1840+
}
1841+
18231842
private static List<String> stateSavedAtLines(AndroidStateSlot slot) {
18241843
String formatted = formatStateSavedAt(slot == null ? null : slot.savedAt());
18251844
return formatted == null ? List.of() : List.of(formatted);

android/app/src/test/java/eu/rekawek/coffeegb/android/AndroidStateSlotTest.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.junit.Test;
99

1010
import java.time.Instant;
11+
import java.util.List;
1112

1213
import static org.junit.Assert.assertEquals;
1314
import static org.junit.Assert.assertFalse;
@@ -58,4 +59,21 @@ public void availableSlotCarriesItsValidatedCatalogSavedTime() {
5859
assertEquals(savedAt, slot.savedAt());
5960
assertTrue(MainActivity.formatStateSavedAt(savedAt).startsWith("SAVED "));
6061
}
62+
63+
@Test
64+
public void stateMenuAlwaysExposesTenSlotsAndMarksOnlyPersistedRows() {
65+
List<AndroidStateSlot> catalog = List.of(
66+
AndroidStateSlot.from(0, null),
67+
new AndroidStateSlot(7, "Slot 7: Saved", true, MenuPreview.empty(), null));
68+
69+
List<eu.rekawek.coffeegb.ui.menu.MenuPageSpec.Item> items =
70+
MainActivity.stateMenuItems(catalog);
71+
72+
assertEquals(10, items.size());
73+
assertEquals("slot:0", items.get(0).id());
74+
assertEquals("slot:9", items.get(9).id());
75+
assertEquals("", items.get(0).detail());
76+
assertEquals("USED", items.get(7).detail());
77+
assertTrue(items.stream().allMatch(eu.rekawek.coffeegb.ui.menu.MenuPageSpec.Item::enabled));
78+
}
6179
}

swing/src/main/java/eu/rekawek/coffeegb/swing/SwingGui.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,11 @@ class SwingGui private constructor(
281281
if (::desktopActions.isInitialized) {
282282
desktopActions.updatePortableStateSlots(
283283
catalog.entries.filter { it.ref is eu.rekawek.coffeegb.controller.state.StateRef.Slot }
284-
.filter { (it.ref as eu.rekawek.coffeegb.controller.state.StateRef.Slot).index in 0..3 }
284+
.filter {
285+
(it.ref as eu.rekawek.coffeegb.controller.state.StateRef.Slot).index in
286+
eu.rekawek.coffeegb.controller.state.StateRef.MIN_SLOT..
287+
eu.rekawek.coffeegb.controller.state.StateRef.MAX_SLOT
288+
}
285289
.map { entry ->
286290
val ref = entry.ref as eu.rekawek.coffeegb.controller.state.StateRef.Slot
287291
val image = entry.thumbnail

swing/src/main/java/eu/rekawek/coffeegb/swing/SwingProposal3Menu.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -366,14 +366,22 @@ internal class SwingProposal3Menu(
366366
}
367367

368368
MenuRoute.SAVE_STATES -> {
369+
val catalog = commands.stateSlots()
369370
val slots =
370-
(0..3).map { slot ->
371-
item("slot-$slot", "SLOT $slot", true)
371+
(0..9).map { slot ->
372+
// "USED" is presentation metadata only: the shared compositor turns it into the
373+
// occupied-slot seal without showing legacy status text in the state list.
374+
MenuPageSpec.Item(
375+
"slot-$slot",
376+
"SLOT $slot",
377+
if (catalog.firstOrNull { it.index == slot }?.loadable == true) "USED" else "",
378+
true,
379+
)
372380
}
373381
val mode = if (stateMenuMode == StateMenuMode.SAVE) "SAVE" else "LOAD"
374382
val focused = stateFocusedItemId ?: "slot-0"
375383
val focusedSlot =
376-
commands.stateSlots().firstOrNull { "slot-${it.index}" == focused }
384+
catalog.firstOrNull { "slot-${it.index}" == focused }
377385
val preview =
378386
focusedSlot?.preview ?: MenuPreview.empty()
379387
val savedAt = portableStateSavedAt(focusedSlot?.savedAt)
@@ -630,7 +638,7 @@ internal class SwingProposal3Menu(
630638
}
631639
stateFocusedItemId = id
632640
if (stateMenuMode == StateMenuMode.LOAD) {
633-
// The detached catalog is authoritative for the on-screen four-slot page. Do not
641+
// The detached catalog is authoritative for the on-screen ten-slot page. Do not
634642
// fall back to the desktop toolbar's separately selected slot while the catalog is
635643
// loading or when this focused slot is empty.
636644
if (commands.stateSlots().firstOrNull { it.index == slot }?.loadable == true) {

swing/src/test/java/eu/rekawek/coffeegb/swing/SwingProposal3MenuTest.kt

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ class SwingProposal3MenuTest {
390390
}
391391

392392
@Test
393-
fun `save page has four stable rows and A or Start saves directly while B returns`() {
393+
fun `save page exposes ten slots and A or Start saves directly while B returns`() {
394394
val bridge = FakeBridge()
395395
val menu = newMenu(bridge)
396396

@@ -406,15 +406,40 @@ class SwingProposal3MenuTest {
406406
press(menu, MenuKey.START)
407407
assertEquals(1, bridge.savedSlot)
408408

409-
repeat(2) { press(menu, MenuKey.DOWN) }
410-
assertEquals("slot-3", menu.focusedItemIdForTest())
409+
repeat(8) { press(menu, MenuKey.DOWN) }
410+
assertEquals("slot-9", menu.focusedItemIdForTest())
411+
press(menu, MenuKey.A)
412+
assertEquals(9, bridge.savedSlot)
411413
press(menu, MenuKey.DOWN)
412414
assertEquals("slot-0", menu.focusedItemIdForTest())
413415
press(menu, MenuKey.B)
414416
assertEquals(MenuRoute.PAUSE_CONSOLE, menu.routeForTest())
415417
}
416418
}
417419

420+
@Test
421+
fun `load page reaches a persisted slot nine`() {
422+
val bridge =
423+
FakeBridge().also {
424+
it.stateCatalog = listOf(PortableMenuStateSlot(9, true, MenuPreview.empty()))
425+
}
426+
val menu = newMenu(bridge)
427+
428+
javax.swing.SwingUtilities.invokeAndWait {
429+
press(menu, MenuKey.DOWN)
430+
press(menu, MenuKey.DOWN)
431+
press(menu, MenuKey.A)
432+
assertEquals(MenuRoute.SAVE_STATES, menu.routeForTest())
433+
434+
repeat(9) { press(menu, MenuKey.DOWN) }
435+
assertEquals("slot-9", menu.focusedItemIdForTest())
436+
press(menu, MenuKey.A)
437+
}
438+
439+
assertEquals(9, bridge.loadedSlot)
440+
assertFalse(menu.visible())
441+
}
442+
418443
@Test
419444
fun `physical controller state routes and actions are marshalled to the EDT`() {
420445
val saveBridge = FakeBridge()

ui-portable/src/main/java/eu/rekawek/coffeegb/ui/menu/MenuPages.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,13 @@ static MenuPage statePage(boolean load) {
143143
item("slot-0", "SLOT 0"),
144144
item("slot-1", "SLOT 1"),
145145
item("slot-2", "SLOT 2"),
146-
item("slot-3", "SLOT 3")),
146+
item("slot-3", "SLOT 3"),
147+
item("slot-4", "SLOT 4"),
148+
item("slot-5", "SLOT 5"),
149+
item("slot-6", "SLOT 6"),
150+
item("slot-7", "SLOT 7"),
151+
item("slot-8", "SLOT 8"),
152+
item("slot-9", "SLOT 9")),
147153
1, List.of("D-PAD MOVE", "A " + (load ? "LOAD" : "SAVE"), "B BACK"),
148154
"slot-0", MenuPreview.empty());
149155
}

0 commit comments

Comments
 (0)