diff --git a/CMakeLists.txt b/CMakeLists.txt index 1d8947a..0b00526 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,6 +158,7 @@ set(AKBASIC_SOURCES src/runtime_struct.c src/runtime_structure.c src/runtime_trap.c + src/runtime_ui.c src/scanner.c src/sink_stdio.c src/sink_tee.c @@ -198,6 +199,7 @@ if(AKBASIC_WITH_AKGL) src/input_akgl.c src/sink_akgl.c src/sprite_akgl.c + src/ui_akgl.c ) target_compile_options(akbasic_akgl PRIVATE -Wall -Wextra) target_link_libraries(akbasic_akgl PUBLIC akbasic akgl) @@ -317,6 +319,7 @@ set(AKBASIC_TESTS struct_types trap_verbs symtab + ui_verbs unnumbered user_functions value_arithmetic diff --git a/MAINTENANCE.md b/MAINTENANCE.md index ab1d195..8fa8886 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -694,6 +694,7 @@ a missing decision rather than a free pass. | `basic screenshot=NAME` | Also the source of `docs/images/NAME.png`. The harness checks the file exists; `tools/docs_screenshots.sh` is what makes it | | `basic size=WxH` | That figure's surface, when 320x200 is not the size the point needs | | `basic text=1` | That figure is drawn **with** the text layer, for a program whose output is characters | +| `basic ui=1` | That figure lends the program a UI device. It gets a font, because widgets draw text, and **not** the text layer, which owns every pixel of the rows it covers and would black the picture out under them | Attributes combine: `basic requires=akgl setup=ship` is a real tag in `docs/08-sprites.md`. diff --git a/TODO.md b/TODO.md index 544101c..b1c8934 100644 --- a/TODO.md +++ b/TODO.md @@ -2483,6 +2483,31 @@ given. And `akgl_controller_poll_key()` survives alongside the keystroke form on and `GETKEY` still use it, because a script asking "was the up arrow pressed" wants a keycode and would get the empty string from the other one. +**The UI group left four things on the floor, and none of them is filed.** Group K — +`MENU`, `GETMENU`, `RMENU`, `DIALOG`, `HUD` and `UISTYLE`, over `akgl_ui` — deliberately +covers libakgl's three widget helpers and nothing else. What a program cannot have, and +what closing each would cost: + +1. **A top-centre or bottom-centre `HUD` anchor.** `akgl_UiAnchor` has the four corners + and dead centre, so `HUD` has exactly those five. Closing it upstream is two appended + enum values and two arms in `akgl_ui_label()`'s switch (`deps/libakgl/src/ui.c`), using + the `CLAY_ATTACH_POINT_CENTER_TOP` and `..._CENTER_BOTTOM` constants the dialog helper + in the same file already uses, with the horizontal inset dropped. Appending keeps 0 + through 4 meaning what they mean, so it is additive. **Not filed, and not done here: + changing a dependency submodule is that repository's decision, not this one's.** +2. **Per-widget styles.** `UISTYLE` sets one look shared by everything, because + `akgl_UiStyle` is per-call and threading an optional style number through three verbs' + argument shapes buys less than it costs. Nothing upstream is missing. +3. **Anything of clay beyond the three helpers** — containers, scrolling, images in + panels, custom elements, hit-testing. Reachable from C against `akgl/ui.h` in the same + window; deliberately not reachable from BASIC. +4. **A dialog the player can dismiss.** libakgl has no dismiss contract — a declarative + frame is the visibility flag — so `DIALOG` with no argument is the only way down. + +One cost carried rather than fixed: libakgl re-rasterizes every line of widget text every +frame, which is its own open `TODO.md` item. A `HUD` rebuilt every frame is the expensive +case. Recorded in `docs/19-user-interface.md` so a program author is not surprised by it. + **`FILTER` is the one verb still refused**, and it is not filed as a gap because upstream said plainly what it would take: `akgl_audio_*` synthesises raw waveforms and mixes them, there is no filter stage to configure, and SDL3 supplies no primitive to build one from. Until an @@ -2514,14 +2539,14 @@ requirement; two cases have diverged on purpose since, and | Gate | Result | |---|---| -| `ctest` | 109/109 — 41 reference golden cases, 23 local ones, 40 unit tests, 1 known-failing, 3 embedding examples, and `docs_examples` | -| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 109/109 headless, with `akgl_typing` skipping itself. The same set minus the three `no_device` cases the SDL driver contradicts, plus `akgl_backends`, `akgl_frontend`, `docs_screenshots` and `akgl_typing` — the last of which is the skip, and the `akgl_build` CI job is where it skips | -| `docs_examples` | Every fenced block in `README.md`, `MAINTENANCE.md` and `docs/` executed and byte-compared: 55 programs, 9 transcripts, 63 output comparisons, 2 excerpts, 2 shell blocks and 8 figures in the default build. The C-snippet count reads 0 when the harness is run by hand without `--cflags-file`; CTest passes it. `MAINTENANCE.md` documents the fence-tag convention | -| `docs_screenshots` | 8/8 figures re-rendered and byte-identical to the checked-in PNGs. AKGL build only — rendering a picture needs the SDL half | +| `ctest` | 112/112 — 41 reference golden cases, 24 local ones, 41 unit tests, 1 known-failing, 3 embedding examples, and `docs_examples` | +| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 112/112 headless, with `akgl_typing` skipping itself. The same set minus the four `no_device` cases the SDL driver contradicts, plus `akgl_backends`, `akgl_frontend`, `docs_screenshots` and `akgl_typing` — the last of which is the skip, and the `akgl_build` CI job is where it skips | +| `docs_examples` | Every fenced block in `README.md`, `MAINTENANCE.md` and `docs/` executed and byte-compared: 71 programs, 9 transcripts, 79 output comparisons, 4 C snippets, 2 excerpts, 2 shell blocks and 19 figures in the default build. The C-snippet count reads 0 when the harness is run by hand without `--cflags-file`; CTest passes it. `MAINTENANCE.md` documents the fence-tag convention | +| `docs_screenshots` | 19/19 figures re-rendered and byte-identical to the checked-in PNGs. AKGL build only — rendering a picture needs the SDL half | | Golden corpus | 41/41 byte-exact from `tests/reference/` — **and 41/41 again through the SDL binary**, which is most of what proves the frontend changes no output | -| ASan + UBSan | 109/109 | -| Line coverage | 94.9% (6767/7130) — above the 90% gate | -| Function coverage | 98.5% (447/454) | +| ASan + UBSan | 112/112 | +| Line coverage | 94.1% (7227/7681) — above the 90% gate | +| Function coverage | 97.9% (474/484) | | Warnings | none under `-Wall -Wextra` | | `doxygen Doxyfile` | clean | | Mutation (`src/symtab.c`) | 74.1%, against a gate of 65 — see `.gitea/workflows/ci.yaml` | diff --git a/docs/10-embedding.md b/docs/10-embedding.md index 9d9c3a6..6913778 100644 --- a/docs/10-embedding.md +++ b/docs/10-embedding.md @@ -138,8 +138,12 @@ script's output to a file while you draw. ```c wrap=hostbody PASS(e, akbasic_runtime_set_devices(&RUNTIME, &graphics, &audio, &input, &sprites)); +PASS(e, akbasic_runtime_set_ui(&RUNTIME, &ui)); ``` +The fifth one is set on its own rather than as a fifth argument to the first, because +that signature predates it and had twenty-eight call sites that do not care about menus. + Any of them may be `NULL`, and that is how you withhold a capability: a script given no audio backend gets an error from `SOUND` rather than silence. Each is a record of function pointers, so you can supply your own and never link the graphics library at diff --git a/docs/11-verb-reference.md b/docs/11-verb-reference.md index 49c38c9..3e610ad 100644 --- a/docs/11-verb-reference.md +++ b/docs/11-verb-reference.md @@ -32,6 +32,7 @@ for the reasoning in each case. | `DCLOSE` | `DCLOSE [n]` | Close channel `n`, or every channel. | | `DEF` | `DEF NAME(args) = expr` | Define a function. Multi-line definitions end in `RETURN`. | | `DELETE` | `DELETE [n][-n]` | Delete lines, with the same range forms as `LIST`. | +| `DIALOG` | `DIALOG ["text"]` | Show a text panel across the bottom of the screen. No argument takes it down. See Chapter 19. | | `DIM` | `DIM A#(n [,...])` | Make an array. Subscripts start at zero; `n` is the count. | | `DIM` … `AS` | `DIM S@ AS T`, `DIM P@ AS PTR TO T` | Make a structure, or a strict pointer to one. See Chapter 16. | | `DIRECTORY` | `DIRECTORY` | **Refused.** Needs a directory-reading wrapper that does not exist yet. | @@ -49,12 +50,14 @@ for the reasoning in each case. | `FOR` | `FOR V = a TO b [STEP c]` | Start a counted loop, ended by `NEXT`. | | `GET` | `GET V` | Take a keystroke if one is waiting, without stopping. | | `GETKEY` | `GETKEY V` | Wait for a keystroke, holding the program but not the host. | +| `GETMENU` | `GETMENU n, V%` | Wait for a menu choice, holding the program but not the host. Assigns the entry number. See Chapter 19. | | `GOSUB` | `GOSUB line` | Call a subroutine, returning on `RETURN`. | | `GOTO` | `GOTO line` | Jump to a line or a label. | | `GRAPHIC` | `GRAPHIC mode | CLR` | Choose a screen mode, or clear it. | | `GSHAPE` | `GSHAPE A$, x, y` | Stamp a region saved by `SSHAPE`. | | `HEADER` | `HEADER "name"` | **Refused.** Formats a disk. | | `HELP` | `HELP` | Re-list the line the last error happened on. | +| `HUD` | `HUD n [,anchor, "text"]` | Pin a line of text to a corner or the centre. No text retires the slot; no arguments retire them all. See Chapter 19. | | `IF` | `IF c THEN s [ELSE s]` | Branch. Everything after `THEN` belongs to the condition. | | `INPUT` | `INPUT ["prompt"] V` | Read a line from the user. | | `INPUT#` | `INPUT #n, V` | Read a line from a channel. | @@ -65,6 +68,7 @@ for the reasoning in each case. | `LOAD` | `LOAD "name"` | The other name for `DLOAD`. | | `LOCATE` | `LOCATE x, y` | Move the pixel cursor. | | `LOOP` | `LOOP [WHILE c | UNTIL c]` | End a `DO` loop. | +| `MENU` | `MENU [n [,"item", ...]]` | Show a menu the player picks from. No entries retires it; no arguments retire them all. See Chapter 19. | | `MOVSPR` | `MOVSPR n, ...` | Move a sprite. Four forms; see Chapter 8. | | `NEW` | `NEW` | Erase the program and every variable. | | `NEXT` | `NEXT V` | End a `FOR` loop and advance its counter. | @@ -106,6 +110,7 @@ for the reasoning in each case. | `TROFF` | `TROFF` | Turn line tracing off. | | `TRON` | `TRON` | Turn line tracing on; each line prints its number in brackets. | | `TYPE` | `TYPE NAME` … `END TYPE` | Declare a record, its fields one per line. See Chapter 16. | +| `UISTYLE` | `UISTYLE [fill, edge, ink [,pad [,radius]]]` | The one look every widget draws with. No arguments restores the default. See Chapter 19. | | `VERIFY` | `VERIFY "name"` | Compare the program in memory against a file. | | `VOL` | `VOL n` | Set the overall volume, 0 to 15. | | `WAIT` | `WAIT addr, mask [,xor]` | Poll a byte until it matches. Holds the program. | diff --git a/docs/12-function-reference.md b/docs/12-function-reference.md index 8f26102..46d7336 100644 --- a/docs/12-function-reference.md +++ b/docs/12-function-reference.md @@ -28,6 +28,7 @@ so a call with the wrong number is a syntax error rather than a surprise. | `RCOLLISION` | 2 | `RCOLLISION(n, f)` | What sprite `n` last collided with and how: what, which, the normal, the depth, the point, and which axis to reverse. See Chapter 8. | | `RGR` | 1 | `RGR(f)` | The `GRAPHIC` mode (0), the drawing surface's width (1) or height (2) in pixels, or a character cell's width (3) or height (4). | | `RIGHT` | 2 | `RIGHT(A$, n)` | The rightmost `n` characters. Clamped. | +| `RMENU` | 2 | `RMENU(n, f)` | A menu's state: field 0 the highlighted entry, field 1 whether it has been confirmed. **Reading field 1 clears it.** | | `RWINDOW` | 1 | `RWINDOW(f)` | The current text window's rows (0) or columns (1). Field 2 is a C128 screen mode and is refused. | | `RSPCOLOR` | 1 | `RSPCOLOR(n)` | One of `SPRCOLOR`'s two shared registers, 1 or 2. | | `RSPHIT` | 2 | `RSPHIT(n, f)` | One of `SPRHIT`'s settings for sprite `n`, in `SPRHIT`'s own argument order: 0 the kind, 1 to 4 the two corners. | diff --git a/docs/14-architecture.md b/docs/14-architecture.md index bc44eb4..32bea0f 100644 --- a/docs/14-architecture.md +++ b/docs/14-architecture.md @@ -137,6 +137,7 @@ and the library owns exactly one turn of it. +-- mode == QUIT ? ------------------------------------> return | +-- akbasic_input_service() GETKEY waiting? -----> return (no line runs) + +-- akbasic_ui_service() GETMENU waiting? -----> return (no line runs) +-- akbasic_console_update_clock() +-- akbasic_console_service() SLEEP or WAIT holding? -> return | @@ -600,14 +601,15 @@ closed, a host that cannot tolerate that should refuse over-long lines itself. ## Devices, and how a capability is withheld -The sink and the four device backends are records of function pointers plus whatever +The sink and the five device backends are records of function pointers plus whatever state you hang off `self`. That is the house pattern for anything that varies, and it is what keeps SDL out of the core: the whole test suite runs on a machine with no SDL installed at all. ```text akbasic_runtime_set_devices(rt, graphics, audio, input, sprites) - | | | | + akbasic_runtime_set_ui(rt, ui) | | | | + | | | | | any of them may be NULL, and NULL is not "do nothing" -- it is "refuse by name": diff --git a/docs/19-user-interface.md b/docs/19-user-interface.md new file mode 100644 index 0000000..817c19d --- /dev/null +++ b/docs/19-user-interface.md @@ -0,0 +1,215 @@ +# 19. Menus and dialogs + +Everything in this chapter needs the SDL build and a UI device. Without one each verb +refuses by name: + +```basic requires=noakgl +10 MENU 1, "START", "QUIT" +``` + +```output +? 10 : RUNTIME ERROR MENU needs a UI device and this runtime has none + +``` + +Every picture in this chapter is generated by running the listing above it; see +`MAINTENANCE.md` if you are editing one. + +## Three widgets, and no fourth + +A menu the player picks from, a panel that says something, and a line of text pinned to +a corner. That is the whole set, and it is the set because it is the set +[libakgl](../deps/libakgl/docs/22-ui.md) supplies — these verbs are a thin skin over +`akgl_ui_menu`, `akgl_ui_dialog` and `akgl_ui_label`, and they are deliberately not a +window toolkit. *What is not here*, at the end, says what to do when you want more. + +## MENU + +```basic requires=akgl ui=1 screenshot=menu +10 UISTYLE 1, 8, 2 +20 MENU 1, "START", "OPTIONS", "QUIT" +``` + +![](images/menu.png) + +`MENU n, item$ [, item$ ...]` puts menu `n` on screen with those entries. `n` runs 1 to +4, so a pause menu can sit over a title menu, and there are at most 16 entries — that is +libakgl's own ceiling and not a number invented here. + +The player moves the highlight with the cursor keys or a D-pad and confirms with Return +or a gamepad's south button. The mouse works too: moving onto a row highlights it and +clicking one confirms it. A pointer sitting still claims nothing, so a mouse parked over +the list does not fight the keyboard for the highlight. + +Defining a menu **resets its highlight to the first entry and forgets any unread +choice.** The entries have just changed meaning; an index into the old list is not worth +carrying across, and a stale choice of an entry that no longer exists is worse. + +`MENU n` with nothing after it retires that menu. `MENU` on its own retires all four — +the same convention `SOLID` uses, where no rectangle retires one and no arguments retire +them all. + +## GETMENU + +```basic norun +10 MENU 1, "START", "OPTIONS", "QUIT" +20 GETMENU 1, C% +30 ON C% GOTO 500, 600, 900 +``` + +`GETMENU n, var` parks the program until somebody chooses, then puts the entry's number +in `var` — 1 for the first — and carries on. The variable has to be a numeric one with a +sigil, `C%` or `C!`; a bare `C` is a *label* in this dialect and cannot be assigned to at +all, which is the same rule `GETKEY` keeps. + +**Parking is not blocking.** The interpreter never stops returning to its host: while a +`GETMENU` is unanswered every step comes straight back without advancing the program, so +the frame rate holds, sprites keep moving, `PLAY` keeps draining its queue, and a game +that embeds the interpreter stays in control of its own loop. What stops is the program, +which is the part you asked for. + +Two things end the wait without a choice, and both assign 0 rather than leaving the +program parked forever: retiring the menu, and the host taking the UI device away. A +script waiting on something that no longer exists is worse than a script told nothing +happened. + +## RMENU + +```basic norun +100 REM the same menu, without parking on it +110 IF RMENU(1,1) THEN C% = RMENU(1,0) : GOSUB 800 +120 GOSUB 900 : REM move everything else +130 GOTO 110 +``` + +`RMENU(n, 0)` is the entry currently highlighted. `RMENU(n, 1)` is -1 once the menu has +been confirmed and 0 otherwise, **and reading it clears it** — exactly the contract +`BUMP()` carries, and for the same reason: without it a program that polls in a loop sees +the same choice forever. + +Reading the highlight does not clear anything. Only field 1 consumes. + +This is what a game with a loop of its own wants. `GETMENU` is what a title screen wants. + +## DIALOG + +```basic requires=akgl ui=1 size=480x200 screenshot=dialog +10 UISTYLE 1, 8, 2 +20 DIALOG "IT IS PITCH BLACK." +``` + +![](images/dialog.png) + +A panel across the bottom of the screen with wrapped text on it. `DIALOG text$` puts it +up; `DIALOG` on its own takes it down. + +**There is no dismiss key and no return value.** libakgl's dialog has neither — a panel +is up because the frame declares it — so showing and hiding is the program's business +and `DIALOG` with no argument *is* the dismissal. The usual shape is a `GETKEY` between +the two: + +```basic norun +10 DIALOG "YOU FOUND A KEY." +20 GETKEY K$ +30 DIALOG +``` + +The text wraps but does not scroll or page. A message longer than the panel is clipped by +it, visibly, which is deliberate on libakgl's side: a clipped line is a bug you can see. + +## HUD + +```basic requires=akgl ui=1 size=480x200 screenshot=hud +10 UISTYLE 1, 8, 2 +20 HUD 1, 0, "LIVES 3" +30 HUD 2, 1, "SCORE 00120" +40 HUD 3, 4, "PAUSED" +``` + +![](images/hud.png) + +`HUD n, anchor, text$` pins a line of text somewhere on the screen. `n` is a slot, 1 to +8, and the anchor is one of five places: + +| Anchor | Where | +|---|---| +| 0 | Top left | +| 1 | Top right | +| 2 | Bottom left | +| 3 | Bottom right | +| 4 | Centre | + +Five, and the numbering is not the reading order, because these are libakgl's five +`akgl_UiAnchor` values and its numbering. **There is no top-centre or bottom-centre.** +A title bar is two corners or the middle. + +A slot keeps its text until something replaces or retires it, so a score display is one +`HUD` per change rather than one per frame. `HUD n` retires slot `n` and `HUD` retires +all eight. + +## UISTYLE + +```basic requires=akgl ui=1 screenshot=uistyle +10 UISTYLE 3, 7, 2, 14, 8 +20 MENU 1, "ONE", "TWO" +30 HUD 1, 0, "STYLED" +``` + +![](images/uistyle.png) + +`UISTYLE fill, edge, ink [, padding [, radius]]` sets the one look every widget draws +with. The three colours are ordinary palette indices, 1 to 16, the same ones `COLOR` +takes. `padding` is the gap in pixels between a panel's edge and its text, and between a +`HUD` label and the screen edge it hugs; `radius` rounds the corners, and 0 is square. +Padding defaults to 8 and radius to 0. + +`UISTYLE` with no arguments goes back to libakgl's own default, a dark textbox with +parchment ink. It goes back to the *library's* default rather than to a copy of it, so +"never styled" and "styled back" stay the same thing. + +There is one style, shared. Per-widget looks are not implemented — see below. + +## Who owns the keyboard + +**A menu that is up owns the cursor keys and Return.** It has to: those are how a menu is +driven, and something has to have them. While at least one menu has entries, Up, Down and +Return go to it and never reach `GET`, `GETKEY` or the line editor. + +Everything else still gets through — letters, Escape, function keys, the gamepad buttons +a menu does not use. And retiring the menu gives the keys straight back: + +```basic norun +10 MENU 1, "YES", "NO" +20 GETMENU 1, C% +30 MENU 1 : REM now INPUT can have Return again +40 INPUT "NAME"; N$ +``` + +Forget line 30 and the `INPUT` on line 40 will never see the Return that ends it. That is +the one way these verbs can surprise a program, so it is worth the line. + +## What is not here + +The rest of clay. libakgl builds its widgets on a full immediate-mode layout engine — +containers, flexible sizing, scrolling regions, images inside panels, custom elements, +hit-testing any element — and none of that is reachable from BASIC. A verb per clay +concept would be a second language inside this one. + +Specifically absent, and each on purpose: + +- **Per-widget styles.** One `UISTYLE`, shared by everything. +- **Scrolling.** Neither the dialog nor the menu scrolls; 16 entries is the menu's limit + and a longer one wants sub-screens. +- **Top-centre and bottom-centre HUD anchors**, because `akgl_UiAnchor` has neither. +- **Mouse position, hover state, or clicks on anything but a menu row.** A program cannot + ask where the pointer is. +- **Any way to dismiss a dialog from inside it.** + +A game that needs those is writing C against `akgl/ui.h`, which is the route libakgl +documents for exactly this case, and it can do that in the same window this interpreter +is drawing into — [Chapter 10](10-embedding.md) is how the two meet. + +One cost worth knowing about: libakgl re-rasterizes every line of widget text every +frame. A `HUD` that changes once a level is free; one rebuilt every frame is the +expensive case, and a program that has one should say so with `TI#` before blaming the +interpreter. diff --git a/docs/README.md b/docs/README.md index bd3740d..347f71a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,7 @@ but the earlier chapters, and 18 assumes it. | **[16. Structures](16-structures.md)** | `TYPE`, records, strict pointers, and sharing a C struct with an embedding host | | **[17. Tutorial: Breakout](17-tutorial-breakout.md)** | Build a whole game out of the text grid and two `DATA` sprites, in sixteen steps | | **[18. Tutorial: Breakout with artwork](18-tutorial-breakout-artwork.md)** | Build it again out of loaded artwork, powerups and a drawn colour HUD, in thirteen | +| **[19. Menus and dialogs](19-user-interface.md)** | `MENU`, `DIALOG`, `HUD` and `UISTYLE` — the widgets, and who owns the keyboard | ## The shortest possible start diff --git a/docs/images/dialog.png b/docs/images/dialog.png new file mode 100644 index 0000000..4ccdff7 Binary files /dev/null and b/docs/images/dialog.png differ diff --git a/docs/images/hud.png b/docs/images/hud.png new file mode 100644 index 0000000..891ce52 Binary files /dev/null and b/docs/images/hud.png differ diff --git a/docs/images/menu.png b/docs/images/menu.png new file mode 100644 index 0000000..c70f964 Binary files /dev/null and b/docs/images/menu.png differ diff --git a/docs/images/uistyle.png b/docs/images/uistyle.png new file mode 100644 index 0000000..45fb38d Binary files /dev/null and b/docs/images/uistyle.png differ diff --git a/include/akbasic/akgl.h b/include/akbasic/akgl.h index dbfd930..2c23ef6 100644 --- a/include/akbasic/akgl.h +++ b/include/akbasic/akgl.h @@ -34,6 +34,7 @@ #include #include #include +#include #include /* @@ -76,13 +77,18 @@ * * **0.9.0 is the first one that is additive on its face**, and the floor moves * anyway. It is the `akgl_ui` subsystem -- a vendored clay, an arena, menus, - * HUDs and dialogs -- reached entirely through a new `akgl/ui.h` this target - * does not include, plus three new entry points in `akgl/draw.h` - * (`akgl_draw_filled_rounded_rect`, `akgl_draw_arc`, `akgl_draw_set_clip`). - * Every header this target actually compiles against -- `actor.h`, `sprite.h`, - * `renderer.h`, `text.h`, `registry.h`, `heap.h` -- is byte-identical to 0.8.0, - * so unlike 0.8.0 there is no `sizeof` to get wrong. The status band grew again - * regardless: `AKGL_ERR_UI` makes seven codes and libakgl now owns 256 to 262. + * HUDs and dialogs -- reached through a new `akgl/ui.h`, plus three new entry + * points in `akgl/draw.h` (`akgl_draw_filled_rounded_rect`, `akgl_draw_arc`, + * `akgl_draw_set_clip`). Every header this target already compiled against -- + * `actor.h`, `sprite.h`, `renderer.h`, `text.h`, `registry.h`, `heap.h` -- is + * byte-identical to 0.8.0, so unlike 0.8.0 there is no `sizeof` to get wrong. + * The status band grew again regardless: `AKGL_ERR_UI` makes seven codes and + * libakgl now owns 256 to 262. + * + * `src/ui_akgl.c` reaches the widget helpers -- `akgl_ui_dialog`, + * `akgl_ui_label`, `akgl_ui_menu` and the frame bracket -- and nothing else. + * The raw clay route is deliberately not exposed to a BASIC program; see + * `docs/19-user-interface.md`. * * It also collided with the build rather than the code. 0.9.0 moved eight test * property calls from `set_tests_properties` to `set_property(TEST ...)`, which @@ -113,6 +119,7 @@ #include #include #include +#include /** * @brief One full cursor blink in milliseconds, half on and half off. @@ -604,4 +611,117 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_audio_init_akgl(akbasic_AudioBackend */ akerr_ErrorContext AKERR_NOIGNORE *akbasic_input_init_akgl(akbasic_InputBackend *obj); +/** + * @brief State for the libakgl-backed UI backend. + * + * **This one is retained where the others are not, and that is the point.** + * libakgl's UI is immediate mode: widgets are declared inside a frame bracket + * and clay borrows their text until the bracket closes. A BASIC program declares + * a menu once and expects it to survive several hundred frames. So the verbs + * write here, and akbasic_ui_akgl_render() re-declares the lot once a frame from + * what it finds. The text is copied on the way in for the same reason. + * + * Element ids are generated once at init -- "menu1", "hud1" and so on -- because + * clay identifies an element by a string that has to be the same every frame for + * hover and scroll state to survive between them. + */ +typedef struct +{ + akgl_RenderBackend *renderer; + char fontname[64]; /* registry name this loaded the UI font under */ + uint16_t fontid; /* what clay calls it */ + bool ready; /* akgl_ui_init succeeded and shutdown has not run */ + + /* + * The one style, and whether UISTYLE has set it. When it has not, every + * widget is handed NULL and gets libakgl's default rather than a copy of it + * made here -- so "never styled" and "styled back to the default" stay the + * same thing. + */ + akgl_UiStyle style; + bool styled; + + char dialogtext[AKBASIC_MAX_STRING_LENGTH]; + bool dialogopen; + + char labelid[AKBASIC_UI_MAX_LABELS][8]; + char labeltext[AKBASIC_UI_MAX_LABELS][AKBASIC_MAX_STRING_LENGTH]; + int labelanchor[AKBASIC_UI_MAX_LABELS]; + bool labelset[AKBASIC_UI_MAX_LABELS]; + + char menuid[AKBASIC_UI_MAX_MENUS][8]; + char menuitems[AKBASIC_UI_MAX_MENUS][AKBASIC_UI_MAX_MENU_ITEMS][AKBASIC_MAX_STRING_LENGTH]; + akgl_UiMenu menus[AKBASIC_UI_MAX_MENUS]; +} akbasic_AkglUi; + +/** + * @brief Bring the UI subsystem up and point a UI backend at it. + * + * Loads @p fontpath into the font registry under a name of its own and registers + * that name with clay. **It calls akgl_registry_init_font(), which + * akgl_registry_init() deliberately does not** -- nothing else in this + * repository has needed the font registry, because the text sink opens its font + * with a bare TTF_OpenFont. + * + * @warning akgl_ui_init() is process-global and refuses a second call. One UI + * per process: two runtimes in one process share it. + * + * @param obj Object to initialize, inspect, or modify. + * @param state Storage for the retained widget set; must outlive the backend. + * @param renderer The renderer the host already initialized; not created here. + * @param fontpath Font file the widgets draw with. Required. + * @param fontsize Point size to load it at. + * @param width Layout width, in pixels. Usually the window's. + * @param height Layout height, in pixels. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj`, `state`, `renderer` or `fontpath` is NULL. + * @throws AKBASIC_ERR_VALUE When `fontsize`, `width` or `height` is not positive. + * @throws AKGL_ERR_UI When the subsystem is already up, or clay refuses. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_ui_init_akgl(akbasic_UiBackend *obj, akbasic_AkglUi *state, akgl_RenderBackend *renderer, const char *fontpath, int fontsize, int width, int height); + +/** + * @brief Declare and draw the whole retained widget set, once, for this frame. + * + * Separate from the verbs for the same reason akbasic_sprite_akgl_render() is: + * the interpreter does not own the frame. A host calls this when it is drawing, + * after everything else -- the UI goes on top. + * + * A frame that fails still closes its bracket, so one bad frame is one bad frame + * rather than a wedged subsystem. + * + * @param obj The backend to draw. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL or carries no state. + * @throws AKGL_ERR_UI When clay reported a layout error. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_ui_akgl_render(akbasic_UiBackend *obj); + +/** + * @brief Offer one SDL event to the UI, and say whether it was taken. + * + * The mouse goes to the subsystem, which never consumes a keystroke. The + * keyboard and gamepad go to each menu that has entries, and those **do** + * consume Up, Down and Return -- so a host must route this before it feeds the + * keystroke ring, and a menu that is up owns those keys. Retiring the menu gives + * them back. + * + * @param obj The backend to offer the event to. + * @param event The event; not modified. + * @param consumed Output destination populated by the function. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When any argument is NULL, or `obj` carries no state. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_ui_akgl_handle_event(akbasic_UiBackend *obj, SDL_Event *event, bool *consumed); + +/** + * @brief Take the UI subsystem down and unload the font it loaded. + * + * Idempotent, and never fails: it is called from teardown paths that are already + * unwinding. Call it before the renderer goes. + * + * @param obj The backend to shut down; NULL is a no-op. + */ +void akbasic_ui_akgl_shutdown(akbasic_UiBackend *obj); + #endif // _AKBASIC_AKGL_H_ diff --git a/include/akbasic/frontend.h b/include/akbasic/frontend.h index 34f21b4..25a19f1 100644 --- a/include/akbasic/frontend.h +++ b/include/akbasic/frontend.h @@ -86,6 +86,8 @@ typedef struct akbasic_AkglFrontend akbasic_InputBackend input; akbasic_SpriteBackend sprites; akbasic_AkglSprites spritesstate; + akbasic_UiBackend ui; + akbasic_AkglUi uistate; /** False once the window has been closed; the drive loop stops on it. */ bool running; diff --git a/include/akbasic/runtime.h b/include/akbasic/runtime.h index f592327..f828d69 100644 --- a/include/akbasic/runtime.h +++ b/include/akbasic/runtime.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include @@ -153,6 +154,13 @@ typedef struct akbasic_Runtime akbasic_AudioBackend *audio; akbasic_InputBackend *input; akbasic_SpriteBackend *sprites; + /* + * The fifth one, set on its own through akbasic_runtime_set_ui() rather than + * as a fifth argument to akbasic_runtime_set_devices(). Adding a parameter + * would have rewritten twenty-eight call sites to pass a NULL none of them + * cares about, in tests and documentation that are about something else. + */ + akbasic_UiBackend *ui; /* * The graphics verbs' own state -- mode, color-source bindings, pixel cursor @@ -172,6 +180,9 @@ typedef struct akbasic_Runtime /* GETKEY's hold on the step loop. Same reasoning again: it is the program's. */ akbasic_InputState input_state; + /* Which menus are up, and GETMENU's hold on the step loop. */ + akbasic_UiState ui_state; + /* * The eight sprites, their positions and their collision bits. Same * reasoning as gfx and audio_state, and one more: RSPPOS and RSPRITE read @@ -321,6 +332,21 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_init(akbasic_Runtime *obj, ak */ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_set_devices(akbasic_Runtime *obj, akbasic_GraphicsBackend *graphics, akbasic_AudioBackend *audio, akbasic_InputBackend *input, akbasic_SpriteBackend *sprites); +/** + * @brief Attach the UI backend, where MENU, DIALOG, HUD and UISTYLE land. + * + * Separate from akbasic_runtime_set_devices() rather than a fifth argument to + * it, because that signature has twenty-eight call sites and none of them is + * about the UI. NULL withholds the capability, which is what the standalone + * stdio driver does and what every no-SDL build gets. + * + * @param obj Object to initialize, inspect, or modify. + * @param ui Where MENU, DIALOG, HUD and UISTYLE land; may be NULL. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_set_ui(akbasic_Runtime *obj, akbasic_UiBackend *ui); + /** * @brief Tell the interpreter where the program it is running came from. * diff --git a/include/akbasic/ui.h b/include/akbasic/ui.h new file mode 100644 index 0000000..0137ebd --- /dev/null +++ b/include/akbasic/ui.h @@ -0,0 +1,179 @@ +/** + * @file ui.h + * @brief Declares the UI backend: where MENU, DIALOG, HUD and UISTYLE land. + * + * Same reasoning as graphics.h, audio.h and input.h -- the core library is free + * of SDL and builds with no libakgl present, so the UI verbs call through a + * record of function pointers rather than reaching akgl_ui_* directly. + * + * One thing here is unlike the other four groups, and it shapes the whole + * record. **libakgl's UI is immediate mode and this interpreter is not.** A game + * written against akgl/ui.h re-declares its widgets inside a frame bracket sixty + * times a second and clay borrows the text pointers until the bracket closes; a + * BASIC program says `MENU 1, "START", "QUIT"` once on line 100 and expects it to + * still be there on line 900, several hundred frames later. So the entry points + * below are all *setters*: a verb pushes what it wants into storage the backend + * owns, and the backend re-declares the whole set once a frame from that. A + * BASIC string is transient by construction and is never what clay is handed. + * + * A runtime with no UI backend is the normal case -- the standalone driver has + * none -- so every UI verb refuses with AKBASIC_ERR_DEVICE rather than + * dereferencing a NULL vtable. + */ + +#ifndef _AKBASIC_UI_H_ +#define _AKBASIC_UI_H_ + +#include + +#include +#include + +/** + * @brief How many menus a program may have up at once. + * + * Four rather than one because a pause menu over a title menu is an ordinary + * thing to want, and rather than eight because nothing needs eight and each one + * costs its item storage in the backend. + */ +#define AKBASIC_UI_MAX_MENUS 4 + +/** @brief How many HUD label slots there are. Eight, the way there are eight sprites. */ +#define AKBASIC_UI_MAX_LABELS 8 + +/** + * @brief Most entries one MENU may carry. + * + * This is libakgl's AKGL_UI_MENU_MAX_ITEMS, restated rather than included: the + * core library must not include an akgl header, and a menu longer than a screen + * is a different widget anyway. src/ui_akgl.c asserts the two agree at compile + * time, so raising one without the other is a build error and not a surprise. + */ +#define AKBASIC_UI_MAX_MENU_ITEMS 16 + +/** + * @brief Where on the screen a HUD label pins itself. + * + * These are akgl_UiAnchor's five values and its numbering, so the adaptor maps + * them across without a table. **There is deliberately no top-centre or + * bottom-centre**, because libakgl has neither and this repository does not edit + * its dependencies to add one; see TODO.md. + */ +typedef enum +{ + AKBASIC_UI_ANCHOR_TOP_LEFT = 0, + AKBASIC_UI_ANCHOR_TOP_RIGHT, + AKBASIC_UI_ANCHOR_BOTTOM_LEFT, + AKBASIC_UI_ANCHOR_BOTTOM_RIGHT, + AKBASIC_UI_ANCHOR_CENTER, + AKBASIC_UI_ANCHOR_LIMIT /* one past the last; not an anchor */ +} akbasic_UiAnchor; + +/** + * @brief Where the UI verbs draw. + * + * Every entry point is required. There is no optional one -- akbasic_GraphicsBackend + * has one only because `size` was added after hosts already existed, and this + * record has no such history. + * + * Text arguments are **copied** by the implementation, not borrowed. A BASIC + * string lives in a value from the per-line pool and is gone by the next + * statement, let alone the next frame. + */ +typedef struct akbasic_UiBackend +{ + void *self; + + /** Set the dialog panel's text. NULL or empty retires the panel. */ + akerr_ErrorContext AKERR_NOIGNORE *(*dialog)(struct akbasic_UiBackend *self, const char *text); + + /** + * Set one HUD label slot. NULL text retires the slot. + * + * `slot` is zero-based here; BASIC numbers them from one, and the verb does + * that subtraction. `anchor` is an akbasic_UiAnchor, already range-checked. + */ + akerr_ErrorContext AKERR_NOIGNORE *(*label)(struct akbasic_UiBackend *self, int slot, int anchor, const char *text); + + /** + * Define one menu, replacing whatever it held. `count` 0 retires it. + * + * Defining a menu resets its highlight to the first entry and clears its + * activation latch: the program has just changed what the entries mean, so + * a selection index into the old list is not worth carrying over. + */ + akerr_ErrorContext AKERR_NOIGNORE *(*menu)(struct akbasic_UiBackend *self, int slot, const char *const *items, int count); + + /** + * Read a menu's highlight and its activation latch. + * + * `selected` comes back zero-based. `activated` is true when the entry has + * been confirmed since the latch was last cleared, and `clear` clears it -- + * which is what makes RMENU(n,1) a read-and-clear the way BUMP() is. A menu + * that was never defined reports 0 and false rather than failing: a program + * polling a menu it has retired has asked a reasonable question. + */ + akerr_ErrorContext AKERR_NOIGNORE *(*menu_state)(struct akbasic_UiBackend *self, int slot, int *selected, bool *activated, bool clear); + + /** + * Set the one style every widget draws with. + * + * `padding` and `radius` are pixels. Restoring the library default is + * `style` with a NULL fill -- see akbasic_ui_style_default(). + */ + akerr_ErrorContext AKERR_NOIGNORE *(*style)(struct akbasic_UiBackend *self, akbasic_Color *fill, akbasic_Color *edge, akbasic_Color *ink, double padding, double radius); + + /** Retire every widget and restore the default style. NEW and CLR call this. */ + akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_UiBackend *self); +} akbasic_UiBackend; + +/** + * @brief The UI verbs' own state, which lives on the runtime. + * + * Deliberately small. The widget text is in the backend, because the backend is + * what has to hand clay a pointer that outlives a frame; what is left here is + * what the *interpreter* needs to answer questions about, which is which menus + * exist and whether a GETMENU is holding. + */ +typedef struct +{ + bool menudefined[AKBASIC_UI_MAX_MENUS]; /* MENU n has entries, so it is on screen */ + bool waiting; /* a GETMENU is holding the program */ + int waitmenu; /* ...on this menu, zero-based */ + char variable[64]; /* ...to assign the chosen entry to */ +} akbasic_UiState; + +/* --- Internal API: exposed for the step loop and the tests. --- */ + +struct akbasic_Runtime; + +/** + * @brief Reset the UI state to its power-on values: no menus, nothing holding. + * @param obj Object to initialize, inspect, or modify. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_ui_state_init(akbasic_UiState *obj); + +/** + * @brief Answer whether a GETMENU is still holding, assigning the entry if one was chosen. + * + * Called from akbasic_runtime_step() beside akbasic_input_service() and for the + * same reason: waiting must not mean blocking. While `*blocked` comes back true + * the step executes no source line, so the program stays where the GETMENU left + * it, and the sprite, audio and collision services -- which run *before* this -- + * keep running underneath it. + * + * Two things release the hold rather than wedging the script, both of them the + * rules akbasic_input_service() already keeps: taking the UI device away, and + * retiring the menu that was being waited on. Either assigns 0. + * + * @param obj Object to initialize, inspect, or modify. + * @param blocked Output destination populated by the function; true while a GETMENU is unsatisfied. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When either argument is NULL. + * @throws AKBASIC_ERR_UNDEFINED When the variable the GETMENU named has gone out of scope. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_ui_service(struct akbasic_Runtime *obj, bool *blocked); + +#endif // _AKBASIC_UI_H_ diff --git a/src/frontend_akgl.c b/src/frontend_akgl.c index 05433ed..6144be9 100644 --- a/src/frontend_akgl.c +++ b/src/frontend_akgl.c @@ -176,6 +176,14 @@ akerr_ErrorContext *akbasic_frontend_akgl_init(akbasic_AkglFrontend *obj, const */ PASS(errctx, akbasic_sprite_init_akgl(&obj->sprites, &obj->spritesstate, obj->renderer, &obj->graphicsstate)); + /* + * And the UI, which loads the same font file again -- under a registry name + * of its own, at the same size. That is not a duplicate by accident: the + * sink holds a TTF_Font it opened itself and never registers it, and clay + * addresses a font by registry name, so the two cannot share one entry. + */ + PASS(errctx, akbasic_ui_init_akgl(&obj->ui, &obj->uistate, obj->renderer, + fontpath, fontsize, w, h)); /* * Audio last, in its own subsystem, and allowed to fail. The reference asks @@ -216,6 +224,7 @@ akerr_ErrorContext *akbasic_frontend_akgl_attach(akbasic_AkglFrontend *obj, akba PASS(errctx, akbasic_runtime_set_devices(rt, &obj->graphics, (obj->audioready ? &obj->audio : NULL), &obj->input, &obj->sprites)); + PASS(errctx, akbasic_runtime_set_ui(rt, &obj->ui)); SUCCEED_RETURN(errctx); } @@ -229,6 +238,7 @@ static akerr_ErrorContext AKERR_NOIGNORE *pump_events(akbasic_AkglFrontend *obj) { PREPARE_ERROR(errctx); SDL_Event event; + bool consumed = false; while ( SDL_PollEvent(&event) ) { if ( event.type == SDL_EVENT_QUIT || @@ -236,6 +246,16 @@ static akerr_ErrorContext AKERR_NOIGNORE *pump_events(akbasic_AkglFrontend *obj) obj->running = false; continue; } + /* + * The UI gets first refusal. The mouse half never takes a keystroke, but + * a menu with entries takes Up, Down and Return -- which is why this has + * to come *before* the ring is filled, and why retiring a menu is how a + * program gives those keys back to the line editor. + */ + PASS(errctx, akbasic_ui_akgl_handle_event(&obj->ui, &event, &consumed)); + if ( consumed ) { + continue; + } /* * Everything else goes to libakgl, which pushes key-downs into the ring * the input backend reads before it consults its own control maps. The @@ -302,6 +322,11 @@ akerr_ErrorContext *akbasic_frontend_akgl_pump(void *self, bool *running) * spr_configure(). */ PASS(errctx, akbasic_sprite_akgl_render(&obj->sprites)); + /* + * The UI last of all, over the sprites as well as over the text. A menu the + * player is being asked to act on is not something a sprite may cover. + */ + PASS(errctx, akbasic_ui_akgl_render(&obj->ui)); FAIL_ZERO_RETURN(errctx, SDL_RenderPresent(obj->renderer->sdl_renderer), AKGL_ERR_SDL, "Couldn't present the frame: %s", SDL_GetError()); if ( resume ) { @@ -357,6 +382,7 @@ void akbasic_frontend_akgl_shutdown(akbasic_AkglFrontend *obj) return; } /* Before the renderer goes: these are textures it created. */ + akbasic_ui_akgl_shutdown(&obj->ui); akbasic_sprite_akgl_shutdown(&obj->sprites); akbasic_graphics_akgl_shutdown(&obj->graphics); if ( obj->font != NULL ) { diff --git a/src/runtime.c b/src/runtime.c index c54b7b1..779f785 100644 --- a/src/runtime.c +++ b/src/runtime.c @@ -232,6 +232,7 @@ akerr_ErrorContext *akbasic_runtime_init(akbasic_Runtime *obj, akbasic_TextSink PASS(errctx, akbasic_data_state_init(&obj->data_state)); PASS(errctx, akbasic_disk_state_init(&obj->disk_state)); PASS(errctx, akbasic_audio_state_init(&obj->audio_state)); + PASS(errctx, akbasic_ui_state_init(&obj->ui_state)); PASS(errctx, akbasic_valuepool_init(&obj->valuepool)); PASS(errctx, akbasic_value_zero(&obj->staticTrueValue)); PASS(errctx, akbasic_value_zero(&obj->staticFalseValue)); @@ -265,6 +266,16 @@ akerr_ErrorContext *akbasic_runtime_set_devices(akbasic_Runtime *obj, akbasic_Gr SUCCEED_RETURN(errctx); } +akerr_ErrorContext *akbasic_runtime_set_ui(akbasic_Runtime *obj, akbasic_UiBackend *ui) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, + "NULL runtime in set_ui"); + obj->ui = ui; + SUCCEED_RETURN(errctx); +} + akerr_ErrorContext *akbasic_runtime_set_source_path(akbasic_Runtime *obj, const char *path) { PREPARE_ERROR(errctx); @@ -1756,6 +1767,16 @@ akerr_ErrorContext *akbasic_runtime_step(akbasic_Runtime *obj) SUCCEED_RETURN(errctx); } + /* + * A GETMENU nobody has answered yet holds it the same way, and beside the + * keyboard rather than after the clock because it is the same kind of wait: + * the program is parked on the player, not on time. + */ + PASS(errctx, akbasic_ui_service(obj, &blocked)); + if ( blocked ) { + SUCCEED_RETURN(errctx); + } + /* * SLEEP and WAIT hold the same way GETKEY does, and the clock is refreshed * before they are asked -- a SLEEP that read a stale clock would wake a step diff --git a/src/runtime_housekeeping.c b/src/runtime_housekeeping.c index 773a3f5..4639855 100644 --- a/src/runtime_housekeeping.c +++ b/src/runtime_housekeeping.c @@ -136,6 +136,17 @@ akerr_ErrorContext *akbasic_cmd_new(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, } } + /* + * And every widget comes down. Unlike a sprite there *is* an entry point + * that undefines these, so this one is complete rather than half: a menu the + * deleted program put up would otherwise sit on screen eating the cursor + * keys, with nothing left running that knows how to retire it. + */ + PASS(errctx, akbasic_ui_state_init(&obj->ui_state)); + if ( obj->ui != NULL && obj->ui->clear != NULL ) { + PASS(errctx, obj->ui->clear(obj->ui)); + } + /* * The line counter goes home too. Without this a NEW typed at the REPL * leaves the next entered line filed under wherever the last program diff --git a/src/runtime_ui.c b/src/runtime_ui.c new file mode 100644 index 0000000..77093fb --- /dev/null +++ b/src/runtime_ui.c @@ -0,0 +1,535 @@ +/** + * @file runtime_ui.c + * @brief The group K verbs: MENU, GETMENU, RMENU, DIALOG, HUD and UISTYLE. + * + * Nothing here includes an SDL or a libakgl header. Every call goes through the + * akbasic_UiBackend record the host attached, which is what lets the whole group + * be tested in a build with no SDL on the machine. + * + * None of these is in the Go reference and none is in Commodore BASIC 7.0 -- + * a C128 had no widget toolkit to wrap. They exist because libakgl 0.9.0 grew + * one, and their shapes come from the verbs already here rather than from + * anywhere else: MENU retires the way SOLID does, GETMENU holds the step loop + * the way GETKEY does, and RMENU reads-and-clears the way BUMP does. + * + * The backend is a set of *setters*, not a set of draw calls. libakgl's UI is + * immediate mode -- widgets are re-declared inside a frame bracket every frame + * and clay borrows the text until the bracket closes -- and a BASIC program is + * not. See include/akbasic/ui.h for the whole of that reasoning. + */ + +#include +#include + +#include + +#include +#include +#include +#include + +#include "verbs.h" + +/* Most verbs answer "did something happen"; this is that answer. */ +#define SUCCEED_TRUE(__obj, __dest) \ + do { \ + *(__dest) = &(__obj)->staticTrueValue; \ + } while ( 0 ) + +/** + * @brief Refuse politely when the host lent us no UI device. + * + * Every verb in this file opens with it, and it has to name the verb: "no UI + * device" on its own tells a program author nothing about which line to look at. + */ +static akerr_ErrorContext *require_ui(akbasic_Runtime *obj, const char *verb) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL && verb != NULL), AKERR_NULLPOINTER, + "NULL argument in require_ui"); + FAIL_ZERO_RETURN(errctx, (obj->ui != NULL), AKBASIC_ERR_DEVICE, + "%s needs a UI device and this runtime has none", verb); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Turn a BASIC menu number into a zero-based slot, or refuse. + * + * Menus are numbered from one, the way sprites and colour sources are. Nothing + * in this dialect is numbered from zero and a UI group is a poor place to start. + */ +static akerr_ErrorContext *menu_index(int64_t n, const char *verb, int *dest) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (n >= 1 && n <= AKBASIC_UI_MAX_MENUS), AKBASIC_ERR_BOUNDS, + "%s: menu %" PRId64 " is outside 1..%d", verb, n, AKBASIC_UI_MAX_MENUS); + *dest = (int)(n - 1); + SUCCEED_RETURN(errctx); +} + +/** @brief The same for a HUD slot. */ +static akerr_ErrorContext *label_index(int64_t n, const char *verb, int *dest) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (n >= 1 && n <= AKBASIC_UI_MAX_LABELS), AKBASIC_ERR_BOUNDS, + "%s: slot %" PRId64 " is outside 1..%d", verb, n, AKBASIC_UI_MAX_LABELS); + *dest = (int)(n - 1); + SUCCEED_RETURN(errctx); +} + +/** + * @brief The nth argument of a command or function, or NULL past the end. + * + * A plain walk rather than a wrapper around akbasic_args_numbers(), because + * every verb in this file mixes numbers and strings in one list and that helper + * refuses a string by design -- correctly, for the groups it was written for. + */ +static akbasic_ASTLeaf *nth_argument(akbasic_ASTLeaf *expr, int n) +{ + akbasic_ASTLeaf *arg = NULL; + int i = 0; + + if ( expr == NULL ) { + return NULL; + } + arg = akbasic_leaf_first_argument(expr); + for ( i = 0; i < n && arg != NULL; i++ ) { + arg = arg->next; + } + return arg; +} + +/** @brief How many arguments the verb was given. */ +static int argument_count(akbasic_ASTLeaf *expr) +{ + akbasic_ASTLeaf *arg = NULL; + int count = 0; + + if ( expr == NULL ) { + return 0; + } + for ( arg = akbasic_leaf_first_argument(expr); arg != NULL; arg = arg->next ) { + count += 1; + } + return count; +} + +/** @brief Evaluate argument @p n and insist it is a number. */ +static akerr_ErrorContext *nth_number(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, const char *verb, int n, int64_t *dest) +{ + PREPARE_ERROR(errctx); + akbasic_ASTLeaf *arg = nth_argument(expr, n); + akbasic_Value *value = NULL; + + FAIL_ZERO_RETURN(errctx, (arg != NULL), AKBASIC_ERR_SYNTAX, + "%s is missing argument %d", verb, n + 1); + PASS(errctx, akbasic_runtime_evaluate(obj, arg, &value)); + FAIL_NONZERO_RETURN(errctx, (value->valuetype == AKBASIC_TYPE_STRING), AKBASIC_ERR_TYPE, + "%s expected a number in argument %d", verb, n + 1); + *dest = (value->valuetype == AKBASIC_TYPE_FLOAT) + ? (int64_t)value->floatval : value->intval; + SUCCEED_RETURN(errctx); +} + +/** + * @brief Evaluate argument @p n, insist it is a string, and copy it out. + * + * Copied rather than pointed at. The value it came from lives in the per-line + * pool, and a caller collecting sixteen of these has no promise that sixteen + * separate slots were used -- so a table of pointers into the pool could quietly + * be a table of sixteen pointers to the same string. + */ +static akerr_ErrorContext *nth_string(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, const char *verb, int n, char *dest, size_t len) +{ + PREPARE_ERROR(errctx); + akbasic_ASTLeaf *arg = nth_argument(expr, n); + akbasic_Value *value = NULL; + + FAIL_ZERO_RETURN(errctx, (arg != NULL), AKBASIC_ERR_SYNTAX, + "%s is missing argument %d", verb, n + 1); + PASS(errctx, akbasic_runtime_evaluate(obj, arg, &value)); + FAIL_ZERO_RETURN(errctx, (value->valuetype == AKBASIC_TYPE_STRING), AKBASIC_ERR_TYPE, + "%s expected a string in argument %d", verb, n + 1); + strncpy(dest, value->stringval, len - 1); + dest[len - 1] = '\0'; + SUCCEED_RETURN(errctx); +} + +/** @brief Hand back an integer from a function. */ +static akerr_ErrorContext *integer_result(akbasic_Runtime *obj, int64_t value, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + akbasic_Value *out = NULL; + + PASS(errctx, akbasic_environment_new_value(obj->environment, &out)); + PASS(errctx, akbasic_value_zero(out)); + out->valuetype = AKBASIC_TYPE_INTEGER; + out->intval = value; + *dest = out; + SUCCEED_RETURN(errctx); +} + +/* ---------------------------------------------------------------- MENU --- */ + +akerr_ErrorContext *akbasic_cmd_menu(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + /* + * The entries are copied here rather than pointed at, so that what reaches + * the backend is sixteen distinct strings whatever the value pool did. Four + * kilobytes of stack, once, in a verb that is not on any hot path. + */ + char items[AKBASIC_UI_MAX_MENU_ITEMS][AKBASIC_MAX_STRING_LENGTH]; + const char *pointers[AKBASIC_UI_MAX_MENU_ITEMS]; + int count = 0; + int entries = 0; + int slot = 0; + int i = 0; + int64_t n = 0; + + (void)lval; (void)rval; + PASS(errctx, require_ui(obj, "MENU")); + count = argument_count(expr); + + /* No arguments retires them all, the way a bare SOLID does. */ + if ( count == 0 ) { + for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) { + PASS(errctx, obj->ui->menu(obj->ui, i, NULL, 0)); + obj->ui_state.menudefined[i] = false; + } + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); + } + + PASS(errctx, nth_number(obj, expr, "MENU", 0, &n)); + PASS(errctx, menu_index(n, "MENU", &slot)); + + /* A number and nothing else retires that one. */ + if ( count == 1 ) { + PASS(errctx, obj->ui->menu(obj->ui, slot, NULL, 0)); + obj->ui_state.menudefined[slot] = false; + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); + } + + entries = count - 1; + FAIL_ZERO_RETURN(errctx, (entries <= AKBASIC_UI_MAX_MENU_ITEMS), AKBASIC_ERR_SYNTAX, + "MENU takes at most %d entries and was given %d", + AKBASIC_UI_MAX_MENU_ITEMS, entries); + for ( i = 0; i < entries; i++ ) { + PASS(errctx, nth_string(obj, expr, "MENU", i + 1, items[i], sizeof(items[i]))); + pointers[i] = items[i]; + } + + PASS(errctx, obj->ui->menu(obj->ui, slot, pointers, entries)); + obj->ui_state.menudefined[slot] = true; + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------- GETMENU --- */ + +/** + * @brief Identify the numeric variable a GETMENU assigns into. + * + * Numeric only. The answer is an entry number, and a program that wanted the + * entry's *text* already has it -- it wrote the MENU. + */ +static akerr_ErrorContext *target_variable(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Variable **dest, char *name, size_t len) +{ + PREPARE_ERROR(errctx); + akbasic_ASTLeaf *arg = nth_argument(expr, 1); + + FAIL_ZERO_RETURN(errctx, (arg != NULL), AKBASIC_ERR_SYNTAX, + "GETMENU expected a menu number and a variable"); + /* + * Either numeric spelling -- `C%` or `C!` -- and not a bare `C`, which in + * this dialect is a *label* rather than a variable and cannot be assigned to + * at all. GETKEY refuses one for the same reason. A string is refused + * because the answer is an entry *number*: a program that wanted the entry's + * text already has it, since it wrote the MENU. + */ + FAIL_ZERO_RETURN(errctx, + (arg->leaftype == AKBASIC_LEAF_IDENTIFIER_INT || + arg->leaftype == AKBASIC_LEAF_IDENTIFIER_FLOAT), + AKBASIC_ERR_TYPE, "GETMENU expected a numeric variable such as C%%"); + PASS(errctx, akbasic_environment_get(obj->environment, arg->identifier, dest)); + FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKBASIC_ERR_UNDEFINED, + "GETMENU could not reach the variable %s", arg->identifier); + strncpy(name, arg->identifier, len - 1); + name[len - 1] = '\0'; + SUCCEED_RETURN(errctx); +} + +/** @brief Write an entry number -- or 0 for "released without a choice" -- into a variable. */ +static akerr_ErrorContext *store_entry(akbasic_Variable *variable, int64_t entry) +{ + PREPARE_ERROR(errctx); + int64_t zerosubscript[1] = { 0 }; + + PASS(errctx, akbasic_variable_set_integer(variable, entry, zerosubscript, 1)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_cmd_getmenu(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + akbasic_Variable *variable = NULL; + char name[64]; + int slot = 0; + int selected = 0; + bool activated = false; + int64_t n = 0; + + (void)lval; (void)rval; + PASS(errctx, require_ui(obj, "GETMENU")); + PASS(errctx, nth_number(obj, expr, "GETMENU", 0, &n)); + PASS(errctx, menu_index(n, "GETMENU", &slot)); + PASS(errctx, target_variable(obj, expr, &variable, name, sizeof(name))); + + FAIL_ZERO_RETURN(errctx, obj->ui_state.menudefined[slot], AKBASIC_ERR_STATE, + "GETMENU: menu %" PRId64 " has no entries; define it with MENU first", n); + + PASS(errctx, obj->ui->menu_state(obj->ui, slot, &selected, &activated, true)); + if ( activated ) { + PASS(errctx, store_entry(variable, (int64_t)selected + 1)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); + } + + /* + * Nobody has chosen yet. A C128 would sit here; the library may not, so the + * step loop is told to stop advancing the program counter instead. The + * program stays on this line, the host keeps its frame rate, and the sprite, + * audio and collision services -- which run before this one -- keep running + * underneath. See akbasic_ui_service(). + */ + obj->ui_state.waiting = true; + obj->ui_state.waitmenu = slot; + strncpy(obj->ui_state.variable, name, sizeof(obj->ui_state.variable) - 1); + obj->ui_state.variable[sizeof(obj->ui_state.variable) - 1] = '\0'; + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* --------------------------------------------------------------- RMENU --- */ + +akerr_ErrorContext *akbasic_fn_rmenu(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + int slot = 0; + int selected = 0; + bool activated = false; + int64_t n = 0; + int64_t field = 0; + + (void)lval; (void)rval; + FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER, + "NULL argument in RMENU"); + PASS(errctx, require_ui(obj, "RMENU")); + PASS(errctx, nth_number(obj, expr, "RMENU", 0, &n)); + PASS(errctx, nth_number(obj, expr, "RMENU", 1, &field)); + PASS(errctx, menu_index(n, "RMENU", &slot)); + FAIL_ZERO_RETURN(errctx, (field == 0 || field == 1), AKBASIC_ERR_BOUNDS, + "RMENU: field %" PRId64 " is outside 0..1", field); + + /* + * Field 1 clears the latch and field 0 does not, which is the whole + * difference between them: reading which entry is highlighted must not + * consume the fact that one was chosen, and reading that one was chosen must + * consume it or a polling loop sees the same choice forever. Same contract + * BUMP() carries, and for the same reason. + */ + PASS(errctx, obj->ui->menu_state(obj->ui, slot, &selected, &activated, (field == 1))); + if ( field == 1 ) { + /* + * An integer -1 or 0, not a BOOLEAN. A BOOLEAN value prints as "true" or + * "false" -- the Go reference's %t, kept deliberately and recorded in + * src/value.c -- and `PRINT RMENU(1,1)` saying "false" where every other + * predicate in this dialect says 0 would be a surprise with no upside. + * Both work in `IF RMENU(1,1) THEN`. + */ + PASS(errctx, integer_result(obj, activated ? AKBASIC_TRUE : 0, dest)); + SUCCEED_RETURN(errctx); + } + PASS(errctx, integer_result(obj, (int64_t)selected + 1, dest)); + SUCCEED_RETURN(errctx); +} + +/* -------------------------------------------------------------- DIALOG --- */ + +akerr_ErrorContext *akbasic_cmd_dialog(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + char text[AKBASIC_MAX_STRING_LENGTH]; + int count = 0; + + (void)lval; (void)rval; + PASS(errctx, require_ui(obj, "DIALOG")); + count = argument_count(expr); + + /* + * No argument takes the panel down. libakgl has no dismiss contract at all + * -- a declarative frame is the visibility flag -- so this verb is both the + * only way up and the only way down. + */ + if ( count == 0 ) { + PASS(errctx, obj->ui->dialog(obj->ui, NULL)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); + } + FAIL_ZERO_RETURN(errctx, (count == 1), AKBASIC_ERR_SYNTAX, + "DIALOG takes one string, or nothing at all"); + PASS(errctx, nth_string(obj, expr, "DIALOG", 0, text, sizeof(text))); + PASS(errctx, obj->ui->dialog(obj->ui, text)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* ----------------------------------------------------------------- HUD --- */ + +akerr_ErrorContext *akbasic_cmd_hud(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + char text[AKBASIC_MAX_STRING_LENGTH]; + int count = 0; + int slot = 0; + int i = 0; + int64_t n = 0; + int64_t anchor = 0; + + (void)lval; (void)rval; + PASS(errctx, require_ui(obj, "HUD")); + count = argument_count(expr); + + if ( count == 0 ) { + for ( i = 0; i < AKBASIC_UI_MAX_LABELS; i++ ) { + PASS(errctx, obj->ui->label(obj->ui, i, AKBASIC_UI_ANCHOR_TOP_LEFT, NULL)); + } + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); + } + + PASS(errctx, nth_number(obj, expr, "HUD", 0, &n)); + PASS(errctx, label_index(n, "HUD", &slot)); + + if ( count == 1 ) { + PASS(errctx, obj->ui->label(obj->ui, slot, AKBASIC_UI_ANCHOR_TOP_LEFT, NULL)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); + } + FAIL_ZERO_RETURN(errctx, (count == 3), AKBASIC_ERR_SYNTAX, + "HUD expected a slot, an anchor and a string"); + + PASS(errctx, nth_number(obj, expr, "HUD", 1, &anchor)); + FAIL_ZERO_RETURN(errctx, (anchor >= 0 && anchor < AKBASIC_UI_ANCHOR_LIMIT), + AKBASIC_ERR_BOUNDS, "HUD: anchor %" PRId64 " is outside 0..%d", + anchor, (int)AKBASIC_UI_ANCHOR_LIMIT - 1); + PASS(errctx, nth_string(obj, expr, "HUD", 2, text, sizeof(text))); + PASS(errctx, obj->ui->label(obj->ui, slot, (int)anchor, text)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------- UISTYLE --- */ + +akerr_ErrorContext *akbasic_cmd_uistyle(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + double args[5]; + akbasic_Color fill; + akbasic_Color edge; + akbasic_Color ink; + int count = 0; + double padding = 0.0; + double radius = 0.0; + + (void)lval; (void)rval; + PASS(errctx, require_ui(obj, "UISTYLE")); + PASS(errctx, akbasic_args_numbers(obj, expr, "UISTYLE", args, 5, &count)); + + /* + * No arguments hands the widgets back to libakgl's own default, which is a + * dark textbox with parchment ink. Three NULLs say "the library's", not + * "black": a style is not a thing this interpreter can spell in full, and + * inventing values here would make UISTYLE with no arguments mean something + * different from never having called it. + */ + if ( count == 0 ) { + PASS(errctx, obj->ui->style(obj->ui, NULL, NULL, NULL, 0.0, 0.0)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); + } + FAIL_ZERO_RETURN(errctx, (count >= 3), AKBASIC_ERR_SYNTAX, + "UISTYLE expected a fill, an edge and an ink colour"); + + PASS(errctx, akbasic_graphics_palette((int)args[0], &fill)); + PASS(errctx, akbasic_graphics_palette((int)args[1], &edge)); + PASS(errctx, akbasic_graphics_palette((int)args[2], &ink)); + padding = (count >= 4) ? args[3] : 8.0; + radius = (count >= 5) ? args[4] : 0.0; + FAIL_NONZERO_RETURN(errctx, (padding < 0.0 || radius < 0.0), AKBASIC_ERR_VALUE, + "UISTYLE: padding and radius cannot be negative"); + + PASS(errctx, obj->ui->style(obj->ui, &fill, &edge, &ink, padding, radius)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------- the step loop --- */ + +akerr_ErrorContext *akbasic_ui_state_init(akbasic_UiState *obj) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, + "NULL argument in ui_state_init"); + memset(obj, 0, sizeof(*obj)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_ui_service(akbasic_Runtime *obj, bool *blocked) +{ + PREPARE_ERROR(errctx); + akbasic_Variable *variable = NULL; + int selected = 0; + bool activated = false; + bool released = false; + + FAIL_ZERO_RETURN(errctx, (obj != NULL && blocked != NULL), AKERR_NULLPOINTER, + "NULL argument in ui_service"); + *blocked = false; + if ( !obj->ui_state.waiting ) { + SUCCEED_RETURN(errctx); + } + + /* + * Two ways out that are not a choice, and neither may wedge the script. A + * host is allowed to change its mind about what it lends out, and a program + * is allowed to retire the menu it is waiting on -- from an interrupt + * handler, which is the only place it could -- so both release the hold and + * assign 0. Same rule akbasic_input_service() keeps for a withdrawn keyboard. + */ + if ( obj->ui == NULL ) { + released = true; + } else if ( !obj->ui_state.menudefined[obj->ui_state.waitmenu] ) { + released = true; + } else { + PASS(errctx, obj->ui->menu_state(obj->ui, obj->ui_state.waitmenu, + &selected, &activated, true)); + if ( !activated ) { + *blocked = true; + SUCCEED_RETURN(errctx); + } + } + + obj->ui_state.waiting = false; + PASS(errctx, akbasic_environment_get(obj->environment, obj->ui_state.variable, &variable)); + FAIL_ZERO_RETURN(errctx, (variable != NULL), AKBASIC_ERR_UNDEFINED, + "GETMENU could not reach the variable %s", obj->ui_state.variable); + PASS(errctx, store_entry(variable, released ? 0 : (int64_t)selected + 1)); + SUCCEED_RETURN(errctx); +} diff --git a/src/ui_akgl.c b/src/ui_akgl.c new file mode 100644 index 0000000..66f7ccc --- /dev/null +++ b/src/ui_akgl.c @@ -0,0 +1,414 @@ +/** + * @file ui_akgl.c + * @brief Wires the UI backend record to libakgl's clay-backed widget helpers. + * + * The three helpers -- akgl_ui_dialog, akgl_ui_label and akgl_ui_menu -- and the + * frame bracket around them. Nothing here touches clay directly: a BASIC program + * gets the widgets, not the layout engine, and `CLAY()` blocks are the route a + * game written in C takes instead. + * + * **This file is where immediate mode meets a language that is not.** libakgl + * wants the widget set declared inside a bracket every frame and borrows the + * text until the bracket closes; a BASIC program says `MENU 1, "START"` on line + * 100 and expects it up on line 900. So the record's entry points are setters + * that copy into akbasic_AkglUi, and akbasic_ui_akgl_render() replays the whole + * set once a frame. The verbs never see a frame and the frame never sees a BASIC + * string. + */ + +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include + +/** + * @brief Registry name the UI font is loaded under. + * + * Its own name rather than sharing the sink's, because the sink does not use the + * font registry at all -- it holds a `TTF_Font *` it opened itself -- and because + * a size is baked into a registered handle, so the UI's font is a different + * entry even when it is the same file. + */ +#define UI_FONT_NAME "akbasic.ui" + +/** + * @brief Fail the build if the two menu ceilings ever disagree. + * + * include/akbasic/ui.h restates AKGL_UI_MENU_MAX_ITEMS rather than including an + * akgl header, which is correct and is also exactly the kind of restatement that + * rots. A mismatch would overflow akgl_UiMenu::items, so it is a negative array + * size here rather than a memory error at frame one. + */ +typedef char ui_menu_ceilings_agree[ + (AKBASIC_UI_MAX_MENU_ITEMS == AKGL_UI_MENU_MAX_ITEMS) ? 1 : -1]; + +/** @brief Recover the backend's own state, or say that it has none. */ +static akerr_ErrorContext *state_of(akbasic_UiBackend *self, akbasic_AkglUi **dest) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL), AKERR_NULLPOINTER, + "NULL argument in akgl UI backend"); + *dest = (akbasic_AkglUi *)self->self; + FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKERR_NULLPOINTER, + "akgl UI backend has no state"); + SUCCEED_RETURN(errctx); +} + +/** @brief The style every widget draws with, or NULL for libakgl's own default. */ +static akgl_UiStyle *look_of(akbasic_AkglUi *state) +{ + return state->styled ? &state->style : NULL; +} + +/** @brief The colour conversion, which is the whole impedance mismatch. */ +static SDL_Color to_sdl(akbasic_Color color) +{ + SDL_Color out; + + out.r = color.r; + out.g = color.g; + out.b = color.b; + out.a = color.a; + return out; +} + +/* ------------------------------------------------- the record's setters --- */ + +static akerr_ErrorContext *ui_dialog(akbasic_UiBackend *self, const char *text) +{ + PREPARE_ERROR(errctx); + akbasic_AkglUi *state = NULL; + + PASS(errctx, state_of(self, &state)); + if ( text == NULL || text[0] == '\0' ) { + state->dialogopen = false; + state->dialogtext[0] = '\0'; + SUCCEED_RETURN(errctx); + } + strncpy(state->dialogtext, text, sizeof(state->dialogtext) - 1); + state->dialogtext[sizeof(state->dialogtext) - 1] = '\0'; + state->dialogopen = true; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *ui_label(akbasic_UiBackend *self, int slot, int anchor, const char *text) +{ + PREPARE_ERROR(errctx); + akbasic_AkglUi *state = NULL; + + PASS(errctx, state_of(self, &state)); + FAIL_ZERO_RETURN(errctx, (slot >= 0 && slot < AKBASIC_UI_MAX_LABELS), + AKERR_OUTOFBOUNDS, "HUD slot %d is outside the backend's range", slot); + if ( text == NULL ) { + state->labelset[slot] = false; + state->labeltext[slot][0] = '\0'; + SUCCEED_RETURN(errctx); + } + strncpy(state->labeltext[slot], text, sizeof(state->labeltext[slot]) - 1); + state->labeltext[slot][sizeof(state->labeltext[slot]) - 1] = '\0'; + state->labelanchor[slot] = anchor; + state->labelset[slot] = true; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *ui_menu(akbasic_UiBackend *self, int slot, const char *const *items, int count) +{ + PREPARE_ERROR(errctx); + akbasic_AkglUi *state = NULL; + int i = 0; + + PASS(errctx, state_of(self, &state)); + FAIL_ZERO_RETURN(errctx, (slot >= 0 && slot < AKBASIC_UI_MAX_MENUS), + AKERR_OUTOFBOUNDS, "MENU slot %d is outside the backend's range", slot); + FAIL_ZERO_RETURN(errctx, (count >= 0 && count <= AKBASIC_UI_MAX_MENU_ITEMS), + AKERR_OUTOFBOUNDS, "MENU entry count %d is outside 0..%d", + count, AKBASIC_UI_MAX_MENU_ITEMS); + FAIL_NONZERO_RETURN(errctx, (count > 0 && items == NULL), AKERR_NULLPOINTER, + "MENU was given %d entries and no list", count); + + /* + * Redefining resets the selection and the latch. The entries have just + * changed meaning, so an index into the old list is not worth carrying over + * and an unread activation of an entry that no longer exists is worse. + */ + for ( i = 0; i < count; i++ ) { + FAIL_ZERO_RETURN(errctx, (items[i] != NULL), AKERR_NULLPOINTER, + "MENU entry %d is NULL", i + 1); + strncpy(state->menuitems[slot][i], items[i], sizeof(state->menuitems[slot][i]) - 1); + state->menuitems[slot][i][sizeof(state->menuitems[slot][i]) - 1] = '\0'; + state->menus[slot].items[i] = state->menuitems[slot][i]; + } + for ( i = count; i < AKBASIC_UI_MAX_MENU_ITEMS; i++ ) { + state->menus[slot].items[i] = NULL; + } + state->menus[slot].count = count; + state->menus[slot].selected = 0; + state->menus[slot].activated = false; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *ui_menu_state(akbasic_UiBackend *self, int slot, int *selected, bool *activated, bool clear) +{ + PREPARE_ERROR(errctx); + akbasic_AkglUi *state = NULL; + + PASS(errctx, state_of(self, &state)); + FAIL_ZERO_RETURN(errctx, (selected != NULL && activated != NULL), AKERR_NULLPOINTER, + "NULL destination in menu_state"); + FAIL_ZERO_RETURN(errctx, (slot >= 0 && slot < AKBASIC_UI_MAX_MENUS), + AKERR_OUTOFBOUNDS, "MENU slot %d is outside the backend's range", slot); + + *selected = (int)state->menus[slot].selected; + *activated = state->menus[slot].activated; + if ( clear ) { + state->menus[slot].activated = false; + } + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *ui_style(akbasic_UiBackend *self, akbasic_Color *fill, akbasic_Color *edge, akbasic_Color *ink, double padding, double radius) +{ + PREPARE_ERROR(errctx); + akbasic_AkglUi *state = NULL; + + PASS(errctx, state_of(self, &state)); + /* + * No colours means the library's default, and the way to say that is to stop + * having a style rather than to copy libakgl's values into ours. Copying them + * would make UISTYLE-with-no-arguments a snapshot of whatever the default was + * on the day this was written. + */ + if ( fill == NULL || edge == NULL || ink == NULL ) { + state->styled = false; + SUCCEED_RETURN(errctx); + } + state->style.fill = to_sdl(*fill); + state->style.edge = to_sdl(*edge); + state->style.ink = to_sdl(*ink); + state->style.padding = (float32_t)padding; + state->style.corner_radius = (float32_t)radius; + state->style.fontid = state->fontid; + state->styled = true; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *ui_clear(akbasic_UiBackend *self) +{ + PREPARE_ERROR(errctx); + akbasic_AkglUi *state = NULL; + int i = 0; + + PASS(errctx, state_of(self, &state)); + state->dialogopen = false; + state->dialogtext[0] = '\0'; + state->styled = false; + for ( i = 0; i < AKBASIC_UI_MAX_LABELS; i++ ) { + state->labelset[i] = false; + state->labeltext[i][0] = '\0'; + } + for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) { + state->menus[i].count = 0; + state->menus[i].selected = 0; + state->menus[i].activated = false; + } + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------- the frame --- */ + +/** + * @brief Declare every retained widget into the open frame. + * + * Its own function because it is three loops, and CATCH inside one escapes only + * the loop -- PASS is the only thing that may appear in here. The caller CATCHes + * this single call, which is what lets it close the bracket on the way out. + * + * Order is deliberate: labels, then the dialog, then the menus. A menu is the + * thing the player is being asked to act on, so nothing else should be able to + * overlap it. + */ +static akerr_ErrorContext AKERR_NOIGNORE *declare_widgets(akbasic_AkglUi *state) +{ + PREPARE_ERROR(errctx); + akgl_UiStyle *look = look_of(state); + int i = 0; + + for ( i = 0; i < AKBASIC_UI_MAX_LABELS; i++ ) { + if ( state->labelset[i] ) { + PASS(errctx, akgl_ui_label(state->labelid[i], state->labeltext[i], + (akgl_UiAnchor)state->labelanchor[i], look)); + } + } + if ( state->dialogopen ) { + PASS(errctx, akgl_ui_dialog("dialog", state->dialogtext, look)); + } + for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) { + if ( state->menus[i].count > 0 ) { + state->menus[i].style = look; + PASS(errctx, akgl_ui_menu(&state->menus[i])); + } + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_ui_akgl_render(akbasic_UiBackend *obj) +{ + PREPARE_ERROR(errctx); + akbasic_AkglUi *state = NULL; + bool opened = false; + + PASS(errctx, state_of(obj, &state)); + if ( !state->ready ) { + SUCCEED_RETURN(errctx); + } + + ATTEMPT { + CATCH(errctx, akgl_ui_frame_begin()); + opened = true; + CATCH(errctx, declare_widgets(state)); + /* + * Cleared before frame_end rather than after, because frame_end closes + * the bracket whether it succeeds or fails -- so a CLEANUP that closed it + * again would be closing somebody else's next frame. + */ + opened = false; + CATCH(errctx, akgl_ui_frame_end(state->renderer)); + } CLEANUP { + if ( opened ) { + IGNORE(akgl_ui_frame_end(state->renderer)); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_ui_akgl_handle_event(akbasic_UiBackend *obj, SDL_Event *event, bool *consumed) +{ + PREPARE_ERROR(errctx); + akbasic_AkglUi *state = NULL; + int i = 0; + + PASS(errctx, state_of(obj, &state)); + FAIL_ZERO_RETURN(errctx, (event != NULL && consumed != NULL), AKERR_NULLPOINTER, + "NULL argument in ui_akgl_handle_event"); + *consumed = false; + if ( !state->ready ) { + SUCCEED_RETURN(errctx); + } + + /* + * The subsystem first: it is mouse-only and never consumes a keystroke, so + * this is safe whatever else is going on. + */ + PASS(errctx, akgl_ui_handle_event(state, event, consumed)); + if ( *consumed ) { + SUCCEED_RETURN(errctx); + } + + /* + * Then each menu that has entries -- and these *do* take Up, Down and + * Return. That is why the loop is over menus with a count rather than over + * all four: a program with no menu up leaves the cursor keys and Return to + * the line editor, which is what makes the REPL usable at all. + */ + for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) { + if ( state->menus[i].count == 0 ) { + continue; + } + PASS(errctx, akgl_ui_menu_handle_event(&state->menus[i], event, consumed)); + if ( *consumed ) { + SUCCEED_RETURN(errctx); + } + } + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------- lifecycle --- */ + +akerr_ErrorContext *akbasic_ui_init_akgl(akbasic_UiBackend *obj, akbasic_AkglUi *state, akgl_RenderBackend *renderer, const char *fontpath, int fontsize, int width, int height) +{ + PREPARE_ERROR(errctx); + int count = 0; + int i = 0; + + FAIL_ZERO_RETURN(errctx, (obj != NULL && state != NULL && fontpath != NULL), + AKERR_NULLPOINTER, "NULL argument in ui_init_akgl"); + FAIL_ZERO_RETURN(errctx, (renderer != NULL), AKERR_NULLPOINTER, + "NULL renderer in ui_init_akgl: the host creates it, not this"); + FAIL_ZERO_RETURN(errctx, (fontsize > 0 && width > 0 && height > 0), AKBASIC_ERR_VALUE, + "A %dx%d UI at %d points is not a UI", width, height, fontsize); + PASS(errctx, akgl_error_init()); + + memset(state, 0, sizeof(*state)); + state->renderer = renderer; + strncpy(state->fontname, UI_FONT_NAME, sizeof(state->fontname) - 1); + + /* + * The element ids, once. clay identifies an element by its string and keeps + * hover and scroll state against it between frames, so these have to be the + * same every frame -- which means they cannot be built on the stack of the + * function that declares them. + */ + for ( i = 0; i < AKBASIC_UI_MAX_LABELS; i++ ) { + snprintf(state->labelid[i], sizeof(state->labelid[i]), "hud%d", i + 1); + } + for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) { + snprintf(state->menuid[i], sizeof(state->menuid[i]), "menu%d", i + 1); + state->menus[i].id = state->menuid[i]; + } + (void)count; + + /* + * **akgl_registry_init() does not initialize the font registry** -- its own + * header says so -- and nothing else in this repository has needed it, + * because the text sink opens its font with a bare TTF_OpenFont and holds + * the handle. akgl_ui_font_register() resolves a *registry name* per use, so + * the font has to go in there. It is idempotent. + */ + PASS(errctx, akgl_registry_init_font()); + PASS(errctx, akgl_text_loadfont(state->fontname, (char *)fontpath, fontsize)); + PASS(errctx, akgl_ui_init(width, height)); + PASS(errctx, akgl_ui_font_register(state->fontname, &state->fontid)); + state->ready = true; + + obj->self = state; + obj->dialog = ui_dialog; + obj->label = ui_label; + obj->menu = ui_menu; + obj->menu_state = ui_menu_state; + obj->style = ui_style; + obj->clear = ui_clear; + SUCCEED_RETURN(errctx); +} + +void akbasic_ui_akgl_shutdown(akbasic_UiBackend *obj) +{ + akbasic_AkglUi *state = NULL; + + if ( obj == NULL || obj->self == NULL ) { + return; + } + state = (akbasic_AkglUi *)obj->self; + if ( !state->ready ) { + return; + } + /* + * The subsystem first, then the font: anything still holding a clay layout + * would be talking to a disowned context after the shutdown, and the font is + * what its text commands resolve through. Both are ignorable -- this is + * called from teardown paths that are already unwinding, and a failure to + * close something down has nowhere useful to go. + */ + IGNORE(akgl_ui_shutdown()); + IGNORE(akgl_text_unloadfont(state->fontname)); + state->ready = false; +} diff --git a/src/verbs.c b/src/verbs.c index f42cb01..6f7bdaf 100644 --- a/src/verbs.c +++ b/src/verbs.c @@ -63,6 +63,7 @@ static const akbasic_Verb VERBS[] = { { "DCLOSE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_optional_arglist, akbasic_cmd_dclose }, { "DEF", AKBASIC_TOK_COMMAND, -1, akbasic_parse_def, akbasic_cmd_def }, { "DELETE", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_delete }, + { "DIALOG", AKBASIC_TOK_COMMAND, -1, akbasic_parse_optional_arglist, akbasic_cmd_dialog }, { "DIM", AKBASIC_TOK_COMMAND, -1, akbasic_parse_dim, akbasic_cmd_dim }, { "DIRECTORY", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, akbasic_parse_optional_arglist, akbasic_cmd_directory }, { "DLOAD", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_dload }, @@ -81,6 +82,7 @@ static const akbasic_Verb VERBS[] = { { "FOR", AKBASIC_TOK_COMMAND, -1, akbasic_parse_for, akbasic_cmd_for }, { "GET", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_get }, { "GETKEY", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_getkey }, + { "GETMENU", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_getmenu }, { "GOSUB", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_gosub }, { "GOTO", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_goto }, { "GRAPHIC", AKBASIC_TOK_COMMAND, -1, akbasic_parse_graphic, akbasic_cmd_graphic }, @@ -88,6 +90,7 @@ static const akbasic_Verb VERBS[] = { { "HEADER", AKBASIC_TOK_COMMAND, -1, akbasic_parse_optional_arglist, akbasic_cmd_header }, { "HELP", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_help }, { "HEX", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_hex }, + { "HUD", AKBASIC_TOK_COMMAND, -1, akbasic_parse_optional_arglist, akbasic_cmd_hud }, { "IF", AKBASIC_TOK_COMMAND, -1, akbasic_parse_if, akbasic_cmd_if }, { "INPUT", AKBASIC_TOK_COMMAND, -1, akbasic_parse_input, akbasic_cmd_input }, /* @@ -109,6 +112,7 @@ static const akbasic_Verb VERBS[] = { { "LOCATE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_locate }, { "LOG", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_log }, { "LOOP", AKBASIC_TOK_COMMAND, -1, akbasic_parse_loop, akbasic_cmd_loop }, + { "MENU", AKBASIC_TOK_COMMAND, -1, akbasic_parse_optional_arglist, akbasic_cmd_menu }, { "MID", AKBASIC_TOK_FUNCTION, 3, NULL, akbasic_fn_mid }, { "MOD", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_mod }, { "MOVSPR", AKBASIC_TOK_COMMAND, -1, akbasic_parse_movspr, akbasic_cmd_movspr }, @@ -140,6 +144,7 @@ static const akbasic_Verb VERBS[] = { { "RETURN", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_return }, { "RGR", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_rgr }, { "RIGHT", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_right }, + { "RMENU", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_rmenu }, { "RSPCOLOR", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_rspcolor }, { "RSPHIT", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_rsphit }, { "RSPPOS", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_rsppos }, @@ -177,6 +182,7 @@ static const akbasic_Verb VERBS[] = { { "TROFF", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_troff }, { "TRON", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_tron }, { "TYPE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_type, akbasic_cmd_type }, + { "UISTYLE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_optional_arglist, akbasic_cmd_uistyle }, { "UNTIL", AKBASIC_TOK_COMMAND, -1, NULL, NULL }, { "USING", AKBASIC_TOK_COMMAND, -1, NULL, NULL }, { "VAL", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_val }, diff --git a/src/verbs.h b/src/verbs.h index 1231132..2e145ff 100644 --- a/src/verbs.h +++ b/src/verbs.h @@ -198,4 +198,12 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_get(struct akbasic_Runtime *obj, akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_getkey(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_scnclr(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +/* Group K user interface verbs and readbacks -- src/runtime_ui.c */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_dialog(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_getmenu(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_hud(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_menu(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_uistyle(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_rmenu(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); + #endif // _AKBASIC_SRC_VERBS_H_ diff --git a/tests/akgl_backends.c b/tests/akgl_backends.c index 5514c49..698df9d 100644 --- a/tests/akgl_backends.c +++ b/tests/akgl_backends.c @@ -42,6 +42,7 @@ #include #include #include +#include #include "testutil.h" @@ -58,6 +59,8 @@ static akbasic_GraphicsBackend GRAPHICS; static akbasic_AkglGraphics GRAPHICSSTATE; static akbasic_InputBackend INPUT; static akbasic_SpriteBackend SPRITES; +static akbasic_UiBackend UI; +static akbasic_AkglUi UISTATE; static akbasic_AkglSprites SPRITESSTATE; static TTF_Font *font = NULL; static char OUTPUT[8192]; @@ -445,6 +448,107 @@ static akerr_ErrorContext AKERR_NOIGNORE *test_input_backend(void) SUCCEED_RETURN(errctx); } +/** + * @brief The UI adaptor: retained widgets, a rendered frame, and a real choice. + * + * The seam this asserts is the one the whole file is about, and here it is more + * than a coordinate conversion. libakgl's UI is immediate mode and this + * interpreter is not, so what has to work is that a MENU declared once survives + * a frame bracket it never saw, and that a keystroke libakgl consumed comes back + * out through the record as an activation a BASIC program can read. + * + * The keystrokes are **keycodes, not scancodes** -- akgl_ui_menu_handle_event + * matches on `event.key.key`, and a scancode-filled event is silently ignored. + */ +static akerr_ErrorContext AKERR_NOIGNORE *test_ui_backend(void) +{ + PREPARE_ERROR(errctx); + const char *items[3] = { "START", "OPTIONS", "QUIT" }; + akbasic_Color fill = { 0x00, 0x00, 0x00, 0xff }; + akbasic_Color edge = { 0xff, 0xff, 0xff, 0xff }; + akbasic_Color ink = { 0xff, 0xff, 0xff, 0xff }; + SDL_Event event; + int selected = 99; + bool activated = true; + bool consumed = false; + + PASS(errctx, akbasic_ui_init_akgl(&UI, &UISTATE, akgl_renderer, + AKBASIC_TEST_FONT, 12, TARGET_SIZE, TARGET_SIZE)); + + /* Nothing declared yet: a frame with no widgets is still a legal frame. */ + PASS(errctx, akbasic_ui_akgl_render(&UI)); + + PASS(errctx, UI.style(&UI, &fill, &edge, &ink, 4.0, 0.0)); + PASS(errctx, UI.label(&UI, 0, AKBASIC_UI_ANCHOR_TOP_LEFT, "LIVES 3")); + PASS(errctx, UI.dialog(&UI, "A PANEL")); + PASS(errctx, UI.menu(&UI, 0, items, 3)); + + /* Freshly defined: first entry highlighted, nothing chosen. */ + PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false)); + TEST_REQUIRE_INT(selected, 0); + TEST_REQUIRE(!activated, "a freshly defined menu should not be activated"); + + /* + * A whole frame, from what the verbs left behind. This is the assertion that + * the retained set is re-declarable: clay borrows the text until frame_end, + * and every pointer it is handed here belongs to UISTATE rather than to a + * BASIC value that stopped existing several statements ago. + */ + PASS(errctx, akbasic_ui_akgl_render(&UI)); + + /* Down, then Return -- the menu owns both while it has entries. */ + memset(&event, 0, sizeof(event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_DOWN; + PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed)); + TEST_REQUIRE(consumed, "a menu that is up should consume Down"); + + memset(&event, 0, sizeof(event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_RETURN; + PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed)); + TEST_REQUIRE(consumed, "a menu that is up should consume Return"); + + PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, true)); + TEST_REQUIRE_INT(selected, 1); + TEST_REQUIRE(activated, "Return should have activated the second entry"); + + /* Reading with clear consumed the latch; the highlight is untouched. */ + PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false)); + TEST_REQUIRE_INT(selected, 1); + TEST_REQUIRE(!activated, "reading the latch should have cleared it"); + + /* + * Retiring the menu hands the cursor keys back. Without this a program that + * put a menu up could never take a typed line again, which is what makes the + * REPL usable at all. + */ + PASS(errctx, UI.menu(&UI, 0, NULL, 0)); + memset(&event, 0, sizeof(event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_DOWN; + PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed)); + TEST_REQUIRE(!consumed, "a retired menu must not still be eating Down"); + + /* Escape is nobody's, even with a menu up. */ + PASS(errctx, UI.menu(&UI, 0, items, 3)); + memset(&event, 0, sizeof(event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_ESCAPE; + PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed)); + TEST_REQUIRE(!consumed, "Escape should reach the program, not the menu"); + + PASS(errctx, UI.clear(&UI)); + PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false)); + TEST_REQUIRE_INT(selected, 0); + PASS(errctx, akbasic_ui_akgl_render(&UI)); + + /* Shutdown is idempotent, because teardown paths are already unwinding. */ + akbasic_ui_akgl_shutdown(&UI); + akbasic_ui_akgl_shutdown(&UI); + SUCCEED_RETURN(errctx); +} + /** * @brief A sprite loaded from an image file lands on the target where MOVSPR put it. * @@ -1225,6 +1329,7 @@ int main(void) CATCH(errctx, test_sink_writes_past_a_short_row()); CATCH(errctx, test_sink_renders()); CATCH(errctx, test_input_backend()); + CATCH(errctx, test_ui_backend()); CATCH(errctx, test_sprite_from_file()); CATCH(errctx, test_sprite_from_pattern()); CATCH(errctx, test_sprite_from_shape()); diff --git a/tests/devices.c b/tests/devices.c index f00c764..4c303ac 100644 --- a/tests/devices.c +++ b/tests/devices.c @@ -57,6 +57,17 @@ static void test_set_devices(void) TEST_REQUIRE(HARNESS_RUNTIME.input == NULL, "input backend should have been detached"); TEST_REQUIRE_STATUS(akbasic_runtime_set_devices(NULL, NULL, NULL, NULL, NULL), AKERR_NULLPOINTER); + + /* + * The fifth device is set on its own, and detaches the same way. It is not a + * fifth argument above because that signature has twenty-eight call sites + * and none of them is about the UI. + */ + TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI)); + TEST_REQUIRE(HARNESS_RUNTIME.ui == &MOCK_UI, "UI backend was not stored"); + TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, NULL)); + TEST_REQUIRE(HARNESS_RUNTIME.ui == NULL, "UI backend should have been detached"); + TEST_REQUIRE_STATUS(akbasic_runtime_set_ui(NULL, NULL), AKERR_NULLPOINTER); harness_stop(); } diff --git a/tests/docs_preludes/hostbody.pre b/tests/docs_preludes/hostbody.pre index 3682a6a..e4d7eba 100644 --- a/tests/docs_preludes/hostbody.pre +++ b/tests/docs_preludes/hostbody.pre @@ -9,12 +9,14 @@ #include #include #include +#include static akbasic_Runtime RUNTIME; static akbasic_GraphicsBackend graphics; static akbasic_AudioBackend audio; static akbasic_InputBackend input; static akbasic_SpriteBackend sprites; +static akbasic_UiBackend ui; akerr_ErrorContext AKERR_NOIGNORE *akbasic_docs_fragment(void); akerr_ErrorContext AKERR_NOIGNORE *akbasic_docs_fragment(void) diff --git a/tests/language/ui/no_device.bas b/tests/language/ui/no_device.bas new file mode 100644 index 0000000..9331b41 --- /dev/null +++ b/tests/language/ui/no_device.bas @@ -0,0 +1,4 @@ +10 REM The standalone driver lends the script no UI device. +20 REM Every verb in group K refuses, and each has to name itself. +30 PRINT "BEFORE" +40 MENU 1, "START", "QUIT" diff --git a/tests/language/ui/no_device.txt b/tests/language/ui/no_device.txt new file mode 100644 index 0000000..2f0aa3c --- /dev/null +++ b/tests/language/ui/no_device.txt @@ -0,0 +1,3 @@ +BEFORE +? 40 : RUNTIME ERROR MENU needs a UI device and this runtime has none + diff --git a/tests/mockdevice.h b/tests/mockdevice.h index 3f6739d..af9d76f 100644 --- a/tests/mockdevice.h +++ b/tests/mockdevice.h @@ -25,6 +25,7 @@ #include #include #include +#include /** @brief Room for the call log. Longer than any test needs; overflow truncates loudly. */ #define MOCK_LOG_SIZE 8192 @@ -60,6 +61,15 @@ typedef struct akbasic_Contact contact; /* what the next contact() call describes */ bool hascontact; int patternbytes[AKBASIC_MAX_SPRITES]; /* bytes the last define() carried, per sprite */ + + /* + * UI. The verbs are setters rather than draw calls, so what a test asserts + * is both the call sequence *and* what the backend now holds -- a MENU that + * logged correctly and stored nothing would still be broken. + */ + int menuselected[AKBASIC_UI_MAX_MENUS]; + bool menuactivated[AKBASIC_UI_MAX_MENUS]; + int menucount[AKBASIC_UI_MAX_MENUS]; } akbasic_MockDevice; static akbasic_MockDevice MOCK; @@ -67,6 +77,7 @@ static akbasic_GraphicsBackend MOCK_GRAPHICS; static akbasic_AudioBackend MOCK_AUDIO; static akbasic_InputBackend MOCK_INPUT; static akbasic_SpriteBackend MOCK_SPRITES; +static akbasic_UiBackend MOCK_UI; /** * @brief Append one formatted call to the log. @@ -483,6 +494,106 @@ static akerr_ErrorContext *mock_spr_contact(akbasic_SpriteBackend *self, int n, /* ---------------------------------------------------------------- fixture -- */ +/* --------------------------------------------------------------------- ui -- */ + +static akerr_ErrorContext *mock_ui_dialog(akbasic_UiBackend *self, const char *text) +{ + PREPARE_ERROR(errctx); + (void)self; + mock_log("dialog %s\n", (text != NULL) ? text : "(none)"); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_ui_label(akbasic_UiBackend *self, int slot, int anchor, const char *text) +{ + PREPARE_ERROR(errctx); + (void)self; + mock_log("label %d anchor %d %s\n", slot, anchor, (text != NULL) ? text : "(none)"); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_ui_menu(akbasic_UiBackend *self, int slot, const char *const *items, int count) +{ + PREPARE_ERROR(errctx); + int i = 0; + (void)self; + + FAIL_ZERO_RETURN(errctx, (slot >= 0 && slot < AKBASIC_UI_MAX_MENUS), AKERR_OUTOFBOUNDS, + "mock menu slot %d", slot); + mock_log("menu %d count %d", slot, count); + for ( i = 0; i < count; i++ ) { + mock_log(" [%s]", (items != NULL && items[i] != NULL) ? items[i] : "(null)"); + } + mock_log("\n"); + MOCK.menucount[slot] = count; + MOCK.menuselected[slot] = 0; + MOCK.menuactivated[slot] = false; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_ui_menu_state(akbasic_UiBackend *self, int slot, int *selected, bool *activated, bool clear) +{ + PREPARE_ERROR(errctx); + (void)self; + + FAIL_ZERO_RETURN(errctx, (selected != NULL && activated != NULL), AKERR_NULLPOINTER, + "NULL destination in mock_ui_menu_state"); + FAIL_ZERO_RETURN(errctx, (slot >= 0 && slot < AKBASIC_UI_MAX_MENUS), AKERR_OUTOFBOUNDS, + "mock menu slot %d", slot); + *selected = MOCK.menuselected[slot]; + *activated = MOCK.menuactivated[slot]; + if ( clear ) { + MOCK.menuactivated[slot] = false; + } + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_ui_style(akbasic_UiBackend *self, akbasic_Color *fill, akbasic_Color *edge, akbasic_Color *ink, double padding, double radius) +{ + PREPARE_ERROR(errctx); + (void)self; + + if ( fill == NULL || edge == NULL || ink == NULL ) { + mock_log("style default\n"); + SUCCEED_RETURN(errctx); + } + mock_log("style #%02x%02x%02x #%02x%02x%02x #%02x%02x%02x pad %.1f radius %.1f\n", + fill->r, fill->g, fill->b, edge->r, edge->g, edge->b, + ink->r, ink->g, ink->b, padding, radius); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *mock_ui_clear(akbasic_UiBackend *self) +{ + PREPARE_ERROR(errctx); + int i = 0; + (void)self; + + mock_log("uiclear\n"); + for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) { + MOCK.menucount[i] = 0; + MOCK.menuselected[i] = 0; + MOCK.menuactivated[i] = false; + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Stand in for the player choosing an entry. + * + * The one thing a mock has to be able to do that no verb can: a real activation + * comes from a keystroke libakgl consumed, and there is no keyboard here. + */ +__attribute__((unused)) +static void mock_menu_choose(int slot, int selected) +{ + if ( slot < 0 || slot >= AKBASIC_UI_MAX_MENUS ) { + return; + } + MOCK.menuselected[slot] = selected; + MOCK.menuactivated[slot] = true; +} + /** @brief Reset the recorder and populate all three vtables. */ __attribute__((unused)) static void mock_devices_init(void) @@ -542,6 +653,14 @@ static void mock_devices_init(void) MOCK_SPRITES.shape = mock_spr_shape; MOCK_SPRITES.solid = mock_spr_solid; MOCK_SPRITES.contact = mock_spr_contact; + + MOCK_UI.self = &MOCK; + MOCK_UI.dialog = mock_ui_dialog; + MOCK_UI.label = mock_ui_label; + MOCK_UI.menu = mock_ui_menu; + MOCK_UI.menu_state = mock_ui_menu_state; + MOCK_UI.style = mock_ui_style; + MOCK_UI.clear = mock_ui_clear; } /** @brief Set what the next collisions() call will report. Bit n-1 is sprite n. */ diff --git a/tests/ui_verbs.c b/tests/ui_verbs.c new file mode 100644 index 0000000..f1143c3 --- /dev/null +++ b/tests/ui_verbs.c @@ -0,0 +1,508 @@ +/** + * @file ui_verbs.c + * @brief Tests the group K verbs against the recording mock backend. + * + * Same arrangement as tests/graphics_verbs.c and for the same reason: these + * verbs produce nothing a golden file can compare, so the assertions are on what + * reached the device. Running a real BASIC line rather than calling the handler + * directly exercises the dispatch-table row and the parse handler too, which is + * where an added verb is most likely to be wrong. + * + * The one thing a mock has to fake is a *choice*: a real activation comes from a + * keystroke libakgl consumed, and there is no keyboard here. mock_menu_choose() + * is that, and it is the only thing in this file that is not a BASIC program. + */ + +#include + +#include +#include +#include + +#include "harness.h" +#include "mockdevice.h" +#include "testutil.h" + +/** @brief Bring up a runtime with the mock UI attached and a program loaded. */ +static akerr_ErrorContext AKERR_NOIGNORE *run_program(const char *source) +{ + PREPARE_ERROR(errctx); + + PASS(errctx, harness_start(NULL)); + mock_devices_init(); + PASS(errctx, akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI)); + PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source)); + PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + SUCCEED_RETURN(errctx); +} + +/** @brief The white of palette index 2, and the black of index 1. */ +#define WHITE "#ffffff" +#define BLACK "#000000" + +/** + * @brief MENU defines, redefines, retires one and retires all. + */ +static void test_menu(void) +{ + TEST_REQUIRE_OK(run_program("10 MENU 1, \"START\", \"QUIT\"\n")); + TEST_REQUIRE_STR(MOCK.log, "menu 0 count 2 [START] [QUIT]\n"); + TEST_REQUIRE(HARNESS_RUNTIME.ui_state.menudefined[0], + "MENU 1 should have marked menu 1 defined"); + harness_stop(); + + /* A number and nothing else retires that one. */ + TEST_REQUIRE_OK(run_program("10 MENU 2, \"A\"\n20 MENU 2\n")); + TEST_REQUIRE_STR(MOCK.log, + "menu 1 count 1 [A]\n" + "menu 1 count 0\n"); + TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.menudefined[1], + "a bare MENU n should have retired menu n"); + harness_stop(); + + /* No arguments retires them all, the way a bare SOLID does. */ + TEST_REQUIRE_OK(run_program("10 MENU 1, \"A\"\n20 MENU 3, \"B\"\n30 MENU\n")); + TEST_REQUIRE_STR(MOCK.log, + "menu 0 count 1 [A]\n" + "menu 2 count 1 [B]\n" + "menu 0 count 0\n" + "menu 1 count 0\n" + "menu 2 count 0\n" + "menu 3 count 0\n"); + harness_stop(); + + /* Entries can be expressions, not only literals. */ + TEST_REQUIRE_OK(run_program("10 A$ = \"LOAD\"\n20 MENU 1, A$ + \" GAME\"\n")); + TEST_REQUIRE_STR(MOCK.log, "menu 0 count 1 [LOAD GAME]\n"); + harness_stop(); +} + +/** + * @brief MENU refuses a slot outside 1..4, a number where a string belongs, and + * more entries than libakgl's menu can hold. + */ +static void test_menu_refusals(void) +{ + TEST_REQUIRE_OK(run_program("10 MENU 0, \"A\"\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "menu 0 is outside 1..4") != NULL, + "MENU 0 should refuse by name, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 MENU 5, \"A\"\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "menu 5 is outside 1..4") != NULL, + "MENU 5 should refuse by name, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 MENU 1, 42\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "MENU expected a string") != NULL, + "a numeric MENU entry should be a type error, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + /* + * A seventeenth entry is refused by the verb, and there is deliberately no + * test for it here: AKBASIC_MAX_TOKENS is 32, so seventeen strings and their + * commas exceed the line's token ceiling and the scanner refuses first. The + * check in akbasic_cmd_menu() is unreachable from BASIC as the scanner + * stands and is kept anyway -- it is the thing that would matter the day + * that ceiling moves, and a MENU that silently wrote past + * akgl_UiMenu::items would be a memory error rather than a diagnostic. + */ +} + +/** + * @brief RMENU reads the highlight without consuming it, and the latch with. + * + * The read-and-clear on field 1 is the whole contract: without it a program + * polling in a loop sees the same choice forever. Same rule BUMP() carries. + */ +static void test_rmenu(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + mock_devices_init(); + TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, + "10 MENU 1, \"A\", \"B\", \"C\"\n" + "20 PRINT RMENU(1,0)\n" + "30 PRINT RMENU(1,1)\n" + "40 PRINT RMENU(1,1)\n" + "50 PRINT RMENU(1,0)\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + /* + * The MENU line first, *then* the choice. Defining a menu resets its + * selection and its latch, so a choice made before line 10 runs is a choice + * into a menu that does not exist yet -- and line 10 would wipe it. + * + * Fifteen steps, not one. A step advances the program counter by one *line + * number*, not one statement, so line 10 runs on step 11 and line 20 on step + * 21; fifteen lands between them. Same reason the frontend's per-frame + * budget is 256. + */ + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 15)); + mock_menu_choose(0, 1); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + /* + * 2 -- entries are numbered from one, so the second is 2 and not 1. + * -1 -- BASIC true, once. + * 0 -- because reading it cleared it. + * 2 -- and reading the latch did not disturb the highlight. + */ + TEST_REQUIRE_STR(HARNESS_OUTPUT, "2\n-1\n0\n2\n"); + harness_stop(); +} + +/** @brief RMENU refuses a bad slot and a field outside 0..1. */ +static void test_rmenu_refusals(void) +{ + TEST_REQUIRE_OK(run_program("10 PRINT RMENU(9,0)\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "menu 9 is outside 1..4") != NULL, + "RMENU should refuse menu 9, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 PRINT RMENU(1,7)\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "field 7 is outside 0..1") != NULL, + "RMENU should refuse field 7, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); +} + +/** + * @brief GETMENU holds the step loop, then assigns the entry that was chosen. + * + * The property being asserted is that the step *returns* while the program does + * not advance -- section 1.6's whole point. A run of a hundred steps with + * nothing chosen must leave the program on the GETMENU line and must come back. + */ +static void test_getmenu_holds(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + mock_devices_init(); + TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, + "10 MENU 1, \"A\", \"B\", \"C\"\n" + "20 GETMENU 1, C%\n" + "30 PRINT C%\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 100)); + TEST_REQUIRE(HARNESS_RUNTIME.ui_state.waiting, + "GETMENU should still be holding after a hundred steps"); + TEST_REQUIRE_STR(HARNESS_OUTPUT, ""); + + mock_menu_choose(0, 2); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.waiting, "GETMENU should have released"); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "3\n"); + harness_stop(); +} + +/** + * @brief A GETMENU that arrives after the choice does not wait at all. + * + * The latch survives between frames precisely so an unread activation is never + * lost, so a program that gets round to its GETMENU late must find the answer + * waiting rather than park for a second one. + */ +static void test_getmenu_takes_a_waiting_choice(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + mock_devices_init(); + TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, + "10 MENU 1, \"A\", \"B\", \"C\"\n" + "20 GETMENU 1, C%\n" + "30 PRINT C%\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + + /* Through line 10 only, then choose before line 20 ever runs. */ + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 15)); + mock_menu_choose(0, 2); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.waiting, + "GETMENU should not have held when the choice was already made"); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "3\n"); + harness_stop(); +} + +/** + * @brief Retiring the menu underneath a holding GETMENU releases it with 0. + * + * Reached from BASIC only through an interrupt handler, which is the one thing + * that can run a line while the program is parked; the menu is retired directly + * here because building a collision interrupt to do it would be testing + * something else. + */ +static void test_getmenu_released_by_retirement(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + mock_devices_init(); + TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, + "10 MENU 1, \"A\", \"B\"\n" + "20 C% = 9\n" + "30 GETMENU 1, C%\n" + "40 PRINT C%\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 35)); + TEST_REQUIRE(HARNESS_RUNTIME.ui_state.waiting, "GETMENU should be holding"); + + TEST_REQUIRE_OK(MOCK_UI.menu(&MOCK_UI, 0, NULL, 0)); + HARNESS_RUNTIME.ui_state.menudefined[0] = false; + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.waiting, + "retiring the menu should have released the GETMENU"); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "0\n"); + harness_stop(); +} + +/** + * @brief Withdrawing the device releases a holding GETMENU rather than wedging it. + * + * A host is allowed to change its mind about what it lends out, and a script + * parked forever on a device that no longer exists is the worse outcome. Same + * rule akbasic_input_service() keeps for a withdrawn keyboard. + */ +static void test_getmenu_released_by_withdrawal(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + mock_devices_init(); + TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, + "10 MENU 1, \"A\", \"B\"\n" + "20 C% = 9\n" + "30 GETMENU 1, C%\n" + "40 PRINT C%\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + /* Through line 30, where the GETMENU is; see the note in test_rmenu(). */ + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 35)); + TEST_REQUIRE(HARNESS_RUNTIME.ui_state.waiting, "GETMENU should be holding"); + + TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, NULL)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.waiting, + "withdrawing the UI should have released the GETMENU"); + /* Released, not chosen: 0 rather than the 9 the variable held. */ + TEST_REQUIRE_STR(HARNESS_OUTPUT, "0\n"); + harness_stop(); +} + +/** @brief GETMENU refuses a menu that has no entries rather than holding forever. */ +static void test_getmenu_needs_a_menu(void) +{ + TEST_REQUIRE_OK(run_program("10 GETMENU 1, C%\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "has no entries") != NULL, + "GETMENU on an undefined menu should refuse, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 MENU 1, \"A\"\n20 GETMENU 1, A$\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "expected a numeric variable") != NULL, + "GETMENU into a string should be a type error, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); +} + +/** @brief DIALOG puts the panel up with a string and takes it down with nothing. */ +static void test_dialog(void) +{ + TEST_REQUIRE_OK(run_program("10 DIALOG \"HELLO\"\n20 DIALOG\n")); + TEST_REQUIRE_STR(MOCK.log, + "dialog HELLO\n" + "dialog (none)\n"); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 DIALOG 42\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "DIALOG expected a string") != NULL, + "a numeric DIALOG should be a type error, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); +} + +/** + * @brief HUD sets each of the five anchors, retires one, and retires all. + * + * Five and not seven. libakgl's akgl_UiAnchor has the four corners and dead + * centre; there is no top-centre or bottom-centre, and this asserts that 5 is + * refused rather than quietly mapped onto something. + */ +static void test_hud(void) +{ + TEST_REQUIRE_OK(run_program("10 HUD 1, 0, \"TL\"\n" + "20 HUD 2, 1, \"TR\"\n" + "30 HUD 3, 2, \"BL\"\n" + "40 HUD 4, 3, \"BR\"\n" + "50 HUD 5, 4, \"MID\"\n")); + TEST_REQUIRE_STR(MOCK.log, + "label 0 anchor 0 TL\n" + "label 1 anchor 1 TR\n" + "label 2 anchor 2 BL\n" + "label 3 anchor 3 BR\n" + "label 4 anchor 4 MID\n"); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 HUD 2, 0, \"X\"\n20 HUD 2\n")); + TEST_REQUIRE_STR(MOCK.log, + "label 1 anchor 0 X\n" + "label 1 anchor 0 (none)\n"); + harness_stop(); + + /* No arguments retires all eight, the way a bare MENU retires all four. */ + TEST_REQUIRE_OK(run_program("10 HUD 1, 0, \"X\"\n20 HUD\n")); + TEST_REQUIRE_STR(MOCK.log, + "label 0 anchor 0 X\n" + "label 0 anchor 0 (none)\n" + "label 1 anchor 0 (none)\n" + "label 2 anchor 0 (none)\n" + "label 3 anchor 0 (none)\n" + "label 4 anchor 0 (none)\n" + "label 5 anchor 0 (none)\n" + "label 6 anchor 0 (none)\n" + "label 7 anchor 0 (none)\n"); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 HUD 1, 5, \"NOPE\"\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "anchor 5 is outside 0..4") != NULL, + "HUD should refuse anchor 5, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 HUD 9, 0, \"NOPE\"\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "slot 9 is outside 1..8") != NULL, + "HUD should refuse slot 9, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + /* A slot and an anchor with nothing to say is a typo, not an empty label. */ + TEST_REQUIRE_OK(run_program("10 HUD 1, 0\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "a slot, an anchor and a string") != NULL, + "a two-argument HUD should be refused, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); +} + +/** @brief UISTYLE converts palette indices, defaults its tail, and resets. */ +static void test_uistyle(void) +{ + TEST_REQUIRE_OK(run_program("10 UISTYLE 1, 2, 2\n")); + TEST_REQUIRE_STR(MOCK.log, + "style " BLACK " " WHITE " " WHITE " pad 8.0 radius 0.0\n"); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 UISTYLE 1, 2, 2, 12, 4\n")); + TEST_REQUIRE_STR(MOCK.log, + "style " BLACK " " WHITE " " WHITE " pad 12.0 radius 4.0\n"); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 UISTYLE\n")); + TEST_REQUIRE_STR(MOCK.log, "style default\n"); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 UISTYLE 17, 2, 2\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "RUNTIME ERROR") != NULL, + "UISTYLE should refuse palette index 17, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 UISTYLE 1, 2\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "a fill, an edge and an ink") != NULL, + "UISTYLE with two colours should be refused, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); +} + +/** @brief NEW takes every widget down; a deleted program's menu must not survive it. */ +static void test_new_retires_everything(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + mock_devices_init(); + TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 MENU 1, \"A\"\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(HARNESS_RUNTIME.ui_state.menudefined[0], "the menu should be up"); + + mock_log_reset(); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 NEW\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(strstr(MOCK.log, "uiclear") != NULL, + "NEW should have retired the widgets, log was \"%s\"", MOCK.log); + TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.menudefined[0], + "NEW should have forgotten the menu"); + harness_stop(); +} + +/** + * @brief Every verb in the group refuses by name when no UI device was lent. + * + * This is the standalone driver's situation and every no-SDL build's, so it is + * the common path rather than an edge case. + */ +static void test_no_device(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 MENU 1, \"A\"\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "MENU needs a UI device") != NULL, + "MENU without a device should name itself, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(harness_start(NULL)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 DIALOG \"X\"\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "DIALOG needs a UI device") != NULL, + "DIALOG without a device should name itself, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(harness_start(NULL)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 HUD 1, 0, \"X\"\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "HUD needs a UI device") != NULL, + "HUD without a device should name itself, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(harness_start(NULL)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 UISTYLE 1, 2, 2\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "UISTYLE needs a UI device") != NULL, + "UISTYLE without a device should name itself, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(harness_start(NULL)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 GETMENU 1, C%\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "GETMENU needs a UI device") != NULL, + "GETMENU without a device should name itself, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + TEST_REQUIRE_OK(harness_start(NULL)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT RMENU(1,0)\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "RMENU needs a UI device") != NULL, + "RMENU without a device should name itself, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); +} + +/** @brief A NULL runtime is refused rather than dereferenced. */ +static void test_set_ui_validation(void) +{ + TEST_REQUIRE_STATUS(akbasic_runtime_set_ui(NULL, NULL), AKERR_NULLPOINTER); + TEST_REQUIRE_STATUS(akbasic_ui_state_init(NULL), AKERR_NULLPOINTER); +} + +int main(void) +{ + test_menu(); + test_menu_refusals(); + test_rmenu(); + test_rmenu_refusals(); + test_getmenu_holds(); + test_getmenu_takes_a_waiting_choice(); + test_getmenu_released_by_retirement(); + test_getmenu_released_by_withdrawal(); + test_getmenu_needs_a_menu(); + test_dialog(); + test_hud(); + test_uistyle(); + test_new_retires_everything(); + test_no_device(); + test_set_ui_validation(); + return akbasic_test_failures; +} diff --git a/tools/docs_screenshots.sh b/tools/docs_screenshots.sh index 55a0fb0..870b9a8 100755 --- a/tools/docs_screenshots.sh +++ b/tools/docs_screenshots.sh @@ -111,25 +111,31 @@ FONT="${ROOT}/assets/fonts/C64_Pro_Mono-STYLE.ttf" # One figure: write the listing to a file, run it, keep the PNG. render() { - local name="$1" body="$2" size="$3" where="$4" setup="$5" text="$6" - local w=320 h=200 out="${OUTDIR}/${name}.png" log="" font="" + local name="$1" body="$2" size="$3" where="$4" setup="$5" text="$6" ui="$7" + local w=320 h=200 out="${OUTDIR}/${name}.png" log="" font="" grid="" if [ -n "${size}" ]; then w="${size%x*}" h="${size#*x}" fi + # Two separate questions, and they were one until the UI verbs arrived. # `text=1` says this program's output is characters rather than drawing, so - # the tool has to render the grid -- see the comment at the head of - # tools/screenshot.c. Everything else gets no font and no text layer. - if [ -n "${text}" ]; then + # the tool has to render the grid. `ui=1` says it draws widgets, which need a + # font but must not have the grid -- the text layer owns every pixel of the + # rows it covers and would black the picture out underneath them. Both need + # the font; only `text=1` asks for the layer. + if [ -n "${text}" ] || [ -n "${ui}" ]; then if [ ! -r "${FONT}" ]; then - echo "FAIL ${where}: text=1 needs a font at ${FONT}" >&2 + echo "FAIL ${where}: text=1 and ui=1 need a font at ${FONT}" >&2 FAILURES=$((FAILURES + 1)) return fi font="${FONT}" fi + if [ -n "${text}" ]; then + grid="1" + fi # The same setup scripts tests/docs_examples.sh uses, run in the same place # relative to the program. A listing that loads `ship.png` needs one whether @@ -153,7 +159,7 @@ render() # stderr, so merging the two would make every sprite figure look like a # failing program. stderr is shown when the tool itself refuses, which is # when it is worth reading. - if ! log="$(cd "${WORK}" && "${TOOL}" "${name}.bas" "${out}" "${w}" "${h}" "${font}" \ + if ! log="$(cd "${WORK}" && "${TOOL}" "${name}.bas" "${out}" "${w}" "${h}" "${font}" "${grid}" \ 2>"${WORK}/${name}.err")"; then echo "FAIL ${where}: ${name} did not render" >&2 cat "${WORK}/${name}.err" >&2 @@ -214,7 +220,7 @@ for DOC in "${DOCS[@]}"; do if [ -n "${NAME}" ]; then render "${NAME}" "${BODY}" "$(attr size "${INFO}")" \ "${DOC}:${START}" "$(attr setup "${INFO}")" \ - "$(attr text "${INFO}")" + "$(attr text "${INFO}")" "$(attr ui "${INFO}")" fi fi ;; diff --git a/tools/screenshot.c b/tools/screenshot.c index 00aeb7f..c5ff434 100644 --- a/tools/screenshot.c +++ b/tools/screenshot.c @@ -75,6 +75,8 @@ static akbasic_GraphicsBackend GRAPHICS; static akbasic_AkglGraphics GRAPHICSSTATE; static akbasic_SpriteBackend SPRITES; static akbasic_AkglSprites SPRITESSTATE; +static akbasic_UiBackend UI; +static akbasic_AkglUi UISTATE; /* * No `window` of our own: akgl/game.h declares one as an unprefixed extern -- * libakgl's TODO calls that a defect and it is -- so a static here shadows it @@ -86,13 +88,15 @@ static char SOURCE[65536]; static void usage(void) { fprintf(stderr, - "usage: akbasic_screenshot [width] [height] [font.ttf]\n" + "usage: akbasic_screenshot [width] [height] [font.ttf] [grid]\n" "\n" " Runs the program against an offscreen renderer of the given size\n" " (default 320x200) and writes what it drew as a PNG.\n" "\n" - " Naming a font draws the text grid as well, for a figure of a\n" - " program whose output is characters rather than drawing.\n"); + " Naming a font lends the program a UI device, because the widgets\n" + " draw text and cannot come up without one. Passing 1 for `grid` as\n" + " well draws the text layer, for a figure of a program whose output\n" + " is characters rather than drawing.\n"); } /** @brief Slurp the program. A figure's listing is small; a partial read is not tolerated. */ @@ -120,7 +124,7 @@ static akerr_ErrorContext AKERR_NOIGNORE *read_program(const char *path) * teardown has one place to happen. */ static akerr_ErrorContext AKERR_NOIGNORE *draw_program(const char *outpath, int w, int h, - const char *fontpath) + const char *fontpath, bool grid) { PREPARE_ERROR(errctx); SDL_Surface *shot = NULL; @@ -160,6 +164,14 @@ static akerr_ErrorContext AKERR_NOIGNORE *draw_program(const char *outpath, int * so the characters the program writes are in the picture. The file comment * explains why the two are not teed together. */ + /* + * The font and the text grid are two separate questions, and they were one + * until the UI verbs arrived. A widget draws text, so a UI figure needs a + * font -- but it does not want the grid, which owns every pixel of the rows + * it covers and would paint the picture black before the widgets landed on + * it. So `fontpath` says "there is a font" and `grid` says "draw the text + * layer too". + */ if ( fontpath == NULL ) { PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL)); } else { @@ -168,12 +180,21 @@ static akerr_ErrorContext AKERR_NOIGNORE *draw_program(const char *outpath, int FONT = TTF_OpenFont(fontpath, (float)AKBASIC_FRONTEND_FONT_SIZE); FAIL_ZERO_RETURN(errctx, (FONT != NULL), AKGL_ERR_SDL, "Couldn't open the font %s: %s", fontpath, SDL_GetError()); - PASS(errctx, akbasic_sink_init_akgl(&SINK, &GRIDSTATE, akgl_renderer, FONT, w, h)); + if ( grid ) { + PASS(errctx, akbasic_sink_init_akgl(&SINK, &GRIDSTATE, akgl_renderer, FONT, w, h)); + } else { + PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL)); + } } PASS(errctx, akbasic_runtime_init(&RUNTIME, &SINK)); PASS(errctx, akbasic_graphics_init_akgl(&GRAPHICS, &GRAPHICSSTATE, akgl_renderer)); PASS(errctx, akbasic_sprite_init_akgl(&SPRITES, &SPRITESSTATE, akgl_renderer, &GRAPHICSSTATE)); PASS(errctx, akbasic_runtime_set_devices(&RUNTIME, &GRAPHICS, NULL, NULL, &SPRITES)); + if ( fontpath != NULL ) { + PASS(errctx, akbasic_ui_init_akgl(&UI, &UISTATE, akgl_renderer, fontpath, + AKBASIC_FRONTEND_FONT_SIZE, w, h)); + PASS(errctx, akbasic_runtime_set_ui(&RUNTIME, &UI)); + } PASS(errctx, akbasic_runtime_load(&RUNTIME, SOURCE)); PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUN)); PASS(errctx, akbasic_runtime_run(&RUNTIME, 0)); @@ -186,10 +207,14 @@ static akerr_ErrorContext AKERR_NOIGNORE *draw_program(const char *outpath, int * and sprites over it, because that ordering is what a program written * against the real host will have assumed. */ - if ( fontpath != NULL ) { + if ( grid ) { PASS(errctx, akbasic_sink_akgl_render(&SINK)); } PASS(errctx, akbasic_sprite_akgl_render(&SPRITES)); + /* Last, and over everything, exactly as src/frontend_akgl.c orders it. */ + if ( fontpath != NULL ) { + PASS(errctx, akbasic_ui_akgl_render(&UI)); + } shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); FAIL_ZERO_RETURN(errctx, (shot != NULL), AKGL_ERR_SDL, @@ -208,6 +233,7 @@ int main(int argc, char **argv) int w = 320; int h = 200; const char *fontpath = NULL; + bool grid = false; if ( argc < 3 ) { usage(); @@ -222,6 +248,9 @@ int main(int argc, char **argv) if ( argc > 5 && argv[5][0] != '\0' ) { fontpath = argv[5]; } + if ( argc > 6 && argv[6][0] == '1' ) { + grid = true; + } if ( w <= 0 || h <= 0 ) { usage(); return 2; @@ -238,8 +267,9 @@ int main(int argc, char **argv) ATTEMPT { CATCH(errctx, read_program(argv[1])); - CATCH(errctx, draw_program(argv[2], w, h, fontpath)); + CATCH(errctx, draw_program(argv[2], w, h, fontpath, grid)); } CLEANUP { + akbasic_ui_akgl_shutdown(&UI); if ( FONT != NULL ) { TTF_CloseFont(FONT); FONT = NULL;