Give BASIC menus, dialogs and HUD labels over libakgl's UI helpers

Group K, and the first verbs to reach the akgl_ui subsystem 0.9.0 brought
in: MENU and GETMENU and RMENU, DIALOG, HUD and UISTYLE. A program that
wanted a title screen had to draw one out of CHAR and GETKEY, which is
what both breakout tutorials make a reader do.

The interesting part is the impedance mismatch. libakgl's UI is immediate
mode -- widgets are re-declared inside a frame bracket every frame and
clay borrows their text until the bracket closes -- and a BASIC program
says MENU 1, "START" on line 100 and expects it up on line 900, several
hundred frames later. So src/ui_akgl.c is retained on this side and
immediate on that one: the record's entry points are setters that copy
into akbasic_AkglUi, and akbasic_ui_akgl_render() replays the whole set
once a frame from the host's pump. No BASIC string, which lives in the
per-line value pool, is ever what clay is handed.

The shapes are borrowed rather than invented. MENU retires the way SOLID
does -- no entries retires one, no arguments retire them all. GETMENU
holds the step loop the way GETKEY does, so parking is not blocking: the
step still returns, the host keeps its frame rate, and the sprite, audio
and collision services keep running underneath because they run before
the blocking checks. RMENU(n,1) reads and clears the way BUMP() does.
Withdrawing the device or retiring the menu releases a holding GETMENU
with 0 rather than wedging the script, which is akbasic_input_service()'s
rule for a withdrawn keyboard.

One thing a program has to know, and docs/19-user-interface.md says it
twice: a menu that is up owns the cursor keys and Return. It has to, and
retiring it gives them back -- forget the MENU n before an INPUT and the
INPUT never sees the Return that ends it.

akbasic_runtime_set_ui() is its own function rather than a fifth argument
to akbasic_runtime_set_devices(), whose signature has twenty-eight call
sites in tests and documentation that are about something else.

deps/libakgl is not touched. akgl_UiAnchor has the four corners and dead
centre, so HUD offers exactly those five; TODO.md records what a
top-centre and bottom-centre would cost upstream, along with the three
other things this deliberately leaves out. No new error code either --
DEVICE, BOUNDS, SYNTAX and TYPE cover the group, and 520 stays free.

tools/screenshot.c had to learn that "needs a font" and "draws the text
grid" are two questions. They were one, and a UI figure came out black:
the text layer owns every pixel of the rows it covers and painted over
the widgets. The new ui=1 fence attribute asks for the first without the
second; MAINTENANCE.md documents it.

112/112 in both configurations, 112/112 under ASan and UBSan, coverage
94.1% against the 90% gate with src/runtime_ui.c at 99% of lines and
100% of functions, doxygen clean, and the four new figures byte-identical
on a re-render. TODO.md section 8's gate table was stale on several
counts besides these and is refreshed with measured numbers.

Co-Authored-By: Tachikoma (Claude Code Opus 5 1M) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
This commit is contained in:
2026-08-02 18:37:10 -04:00
parent 5061419768
commit 3b32a682a1
33 changed files with 2424 additions and 31 deletions

View File

@@ -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

View File

@@ -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`.

39
TODO.md
View File

@@ -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` |

View File

@@ -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

View File

@@ -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. |

View File

@@ -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. |

View File

@@ -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":

215
docs/19-user-interface.md Normal file
View File

@@ -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.

View File

@@ -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

BIN
docs/images/dialog.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

BIN
docs/images/hud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

BIN
docs/images/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 B

BIN
docs/images/uistyle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -34,6 +34,7 @@
#include <akgl/collision.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
#include <akgl/ui.h>
#include <akgl/version.h>
/*
@@ -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 <akbasic/input.h>
#include <akbasic/sink.h>
#include <akbasic/sprite.h>
#include <akbasic/ui.h>
/**
* @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_

View File

@@ -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;

View File

@@ -32,6 +32,7 @@
#include <akbasic/structtype.h>
#include <akbasic/symtab.h>
#include <akbasic/types.h>
#include <akbasic/ui.h>
#include <akbasic/value.h>
#include <akbasic/variable.h>
@@ -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.
*

179
include/akbasic/ui.h Normal file
View File

@@ -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 <akerror.h>
#include <akbasic/graphics.h>
#include <akbasic/types.h>
/**
* @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_

View File

@@ -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 ) {

View File

@@ -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

View File

@@ -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

535
src/runtime_ui.c Normal file
View File

@@ -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 <inttypes.h>
#include <string.h>
#include <akerror.h>
#include <akbasic/args.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include <akbasic/ui.h>
#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);
}

414
src/ui_akgl.c Normal file
View File

@@ -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 <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/registry.h>
#include <akgl/text.h>
#include <akgl/ui.h>
#include <akbasic/akgl.h>
#include <akbasic/error.h>
/**
* @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;
}

View File

@@ -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 },

View File

@@ -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_

View File

@@ -42,6 +42,7 @@
#include <akbasic/sprite.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
#include <akbasic/ui.h>
#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());

View File

@@ -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();
}

View File

@@ -9,12 +9,14 @@
#include <akerror.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
#include <akbasic/ui.h>
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)

View File

@@ -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"

View File

@@ -0,0 +1,3 @@
BEFORE
? 40 : RUNTIME ERROR MENU needs a UI device and this runtime has none

View File

@@ -25,6 +25,7 @@
#include <akbasic/graphics.h>
#include <akbasic/input.h>
#include <akbasic/sprite.h>
#include <akbasic/ui.h>
/** @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. */

508
tests/ui_verbs.c Normal file
View File

@@ -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 <string.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include <akbasic/ui.h>
#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;
}

View File

@@ -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
;;

View File

@@ -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 <program.bas> <output.png> [width] [height] [font.ttf]\n"
"usage: akbasic_screenshot <program.bas> <output.png> [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;