Fullscreen keyboard dialog, nearest-cell vertical navigation, overwrite-at-max-length

- Keyboard dialog now fills the whole screen (no dimming backdrop); key
  cells use a fixed font-tile-based size instead of scaling to a
  percentage of the screen.
- uiMenuFocusSkipBlanks: UP/DOWN now search every non-blank cell strictly
  further in the pressed direction and land on the smallest combined
  row+column distance (ties favor the smaller column distance), instead
  of only scanning straight down the same column. Neither axis wraps
  during that search - only once the edge row is reached with nothing
  further to search does it fall back to wrapping to the opposite edge.
- Typing a new character while the keyboard's text is already at
  maxLength now overrides the last character instead of being ignored.
This commit is contained in:
2026-08-27 21:14:19 -05:00
parent 00bfaf6360
commit 4387d223b9
3 changed files with 156 additions and 66 deletions
+41 -38
View File
@@ -17,11 +17,13 @@
#include "display/color.h"
#include "display/text/font.h"
#include "display/spritebatch/spritebatch.h"
#include "display/texture/texture.h"
#include "display/shader/shaderunlit.h"
#define UI_KEYBOARD_BACKDROP_COLOR color4b(0, 0, 0, 160)
#define UI_KEYBOARD_KEY_WIDTH 20.0f
// Key cell size, expressed in font tiles so it scales if the font ever
// does. Row height is derived from this ratio in uiKeyboardDraw rather
// than being its own fixed constant, so a key cell keeps its proportions
// even when contentWidth is stretched wider by a long title/text label.
#define UI_KEYBOARD_KEY_WIDTH ((float_t)(FONT_DEFAULT_TILE_WIDTH * 5))
#define UI_KEYBOARD_KEY_HEIGHT ((float_t)(FONT_DEFAULT_TILE_HEIGHT * 2))
uikeyboard_t UI_KEYBOARD;
@@ -184,7 +186,14 @@ bool_t uiKeyboardTextIsBlank(void) {
void uiKeyboardAppendChar(const char_t c) {
size_t length = strlen(UI_KEYBOARD.text);
if(length >= UI_KEYBOARD.maxLength) return;
// Full - drop the last character instead of ignoring the keypress, so
// the new one overrides it. Falls through to the normal append below,
// which now has room again.
if(length >= UI_KEYBOARD.maxLength) {
length--;
UI_KEYBOARD.text[length] = '\0';
}
if(c == '\n') {
uint8_t lines = 1;
@@ -456,22 +465,12 @@ void uiKeyboardClose(void) {
errorret_t uiKeyboardDraw(void) {
if(!UI_KEYBOARD.open) errorOk();
spritebatchsprite_t backdropSprite = {
.min = { 0.0f, 0.0f, 0.0f },
.max = { (float_t)SCREEN.width, (float_t)SCREEN.height, 0.0f },
.uvMin = { 0.0f, 0.0f },
.uvMax = { 1.0f, 1.0f }
};
shadermaterial_t backdropMaterial = {
.unlit = {
.color = UI_KEYBOARD_BACKDROP_COLOR,
.texture = &TEXTURE_WHITE
}
};
errorChain(
spriteBatchBuffer(&backdropSprite, 1, &SHADER_UNLIT, backdropMaterial)
);
errorChain(spriteBatchFlush());
float_t x = (float_t)SCREEN.scanX;
float_t y = (float_t)SCREEN.scanY;
float_t width = (float_t)SCREEN.scanWidth;
float_t height = (float_t)SCREEN.scanHeight;
errorChain(uiFrameDraw(x, y, width, height));
float_t rowHeight = (float_t)FONT_DEFAULT.tileset->tileHeight;
// When multi-line, reserve the full lineCount rows up front so the
@@ -481,28 +480,23 @@ errorret_t uiKeyboardDraw(void) {
float_t textHeight = UI_KEYBOARD.lineCount > 1 ?
(float_t)UI_KEYBOARD.lineCount * rowHeight :
mathMax(rowHeight, (float_t)UI_KEYBOARD.textLabel.height);
uint8_t rows = (UI_KEYBOARD.menu.itemCount + UI_KEYBOARD.menu.columns - 1) /
UI_KEYBOARD.menu.columns;
uint8_t columns = UI_KEYBOARD.menu.columns;
uint8_t rows = (UI_KEYBOARD.menu.itemCount + columns - 1) / columns;
float_t contentWidth = mathMax(
mathMax((float_t)UI_KEYBOARD.titleLabel.width, (float_t)UI_KEYBOARD.textLabel.width),
(float_t)UI_KEYBOARD.menu.columns * UI_KEYBOARD_KEY_WIDTH
(float_t)columns * UI_KEYBOARD_KEY_WIDTH
);
float_t width = contentWidth + (UI_FRAME_START_X * 2);
float_t height = (UI_FRAME_START_Y * 2)
+ rowHeight + UI_FRAME_PADDING_Y
float_t colStep = contentWidth / (float_t)columns;
float_t keyRowHeight = colStep * (UI_KEYBOARD_KEY_HEIGHT / UI_KEYBOARD_KEY_WIDTH);
float_t contentHeight = rowHeight + UI_FRAME_PADDING_Y
+ textHeight + UI_FRAME_PADDING_Y
+ ((float_t)rows * rowHeight);
+ ((float_t)rows * keyRowHeight);
float_t x = (float_t)SCREEN.scanX +
((float_t)SCREEN.scanWidth - width) * 0.5f;
float_t y = (float_t)SCREEN.scanY +
((float_t)SCREEN.scanHeight - height) * 0.5f;
errorChain(uiFrameDraw(x, y, width, height));
float_t contentX = x + UI_FRAME_START_X;
float_t contentY = y + UI_FRAME_START_Y;
// The frame now fills the whole screen, but the title/text/keys still
// lay out as one content block - center that block within the screen.
float_t contentX = x + (width - contentWidth) * 0.5f;
float_t contentY = y + (height - contentHeight) * 0.5f;
uiLabelSetX(&UI_KEYBOARD.titleLabel, contentX);
uiLabelSetY(&UI_KEYBOARD.titleLabel, contentY);
@@ -513,8 +507,17 @@ errorret_t uiKeyboardDraw(void) {
uiLabelSetY(&UI_KEYBOARD.textLabel, textY);
errorChain(uiLabelRender(&UI_KEYBOARD.textLabel, COLOR_WHITE));
// Drawn directly rather than via uiMenuDraw - that helper hardcodes row
// spacing to the font's native tile height, which would keep the grid
// vertically compact no matter how much contentWidth grows. Focus
// highlighting is unaffected: it's applied to item->button.highlighted
// by the focus system independently of whoever calls uiButtonDraw.
float_t menuY = textY + textHeight + UI_FRAME_PADDING_Y;
errorChain(uiMenuDraw(&UI_KEYBOARD.menu, contentX, menuY, contentWidth, rowHeight));
for(uint8_t i = 0; i < UI_KEYBOARD.menu.itemCount; i++) {
float_t ix = contentX + (float_t)(i % columns) * colStep;
float_t iy = menuY + (float_t)(i / columns) * keyRowHeight;
errorChain(uiButtonDraw(&UI_KEYBOARD.items[i].button, ix, iy));
}
errorChain(spriteBatchFlush());
errorOk();
+83 -15
View File
@@ -293,34 +293,102 @@ bool_t uiMenuFocusSkipBlanks(
}
if(dx == 0 && dy == 0) return false;
// Only one of dx/dy is ever non-zero (a direction moves along a single
// axis), so the number of distinct cells reachable before this would
// cycle back to the start is exactly that axis's length.
uint8_t attempts = dx != 0 ? menu->columns : focusItem->rows;
// Horizontal: a straight scan along the row. There's no "nearest"
// ambiguity here - the next non-blank cell in the pressed direction is
// always the right one, wrapping within the row as needed.
if(dy == 0) {
int16_t x = focusItem->x;
int16_t y = focusItem->y;
for(uint8_t i = 0; i < attempts; i++) {
for(uint8_t i = 0; i < menu->columns; i++) {
x += dx;
y += dy;
if(x < 0) x += menu->columns;
if(y < 0) y += focusItem->rows;
x %= menu->columns;
y %= focusItem->rows;
uint8_t slot = (uint8_t)y * menu->columns + (uint8_t)x;
uint8_t slot = focusItem->y * menu->columns + (uint8_t)x;
uint8_t index = uiMenuFocusSlotToIndex(menu, slot);
// Treat a missing item (label/spacer slot) the same as a blank
// button - both get skipped over transparently.
if(index != 0xFF && !uiMenuItemIsBlank(&menu->items[index])) {
uiMenuSetPosition(menu, (uint8_t)x, (uint8_t)y);
uiMenuSetPosition(menu, (uint8_t)x, focusItem->y);
return true;
}
}
return false;
}
// Every cell along this line is blank/missing - nothing to land on,
// fall through to the default single-step move.
// Vertical: search every cell strictly further in the pressed
// direction (no wrap - the rows below are only ever "below", never
// "above via the far edge") and land on whichever non-blank cell has
// the smallest combined row+column distance from where we moved from.
// This lets an exact column match several rows away beat an
// approximate match one row away, while still reaching something like
// SPACE from a key with nothing directly below it. Ties on total
// distance favor the smaller column distance - e.g. moving straight
// down two rows onto an exact column match beats landing one row down
// one column over, even though both are three cells away.
int16_t rowStart = dy > 0 ? focusItem->y + 1 : 0;
int16_t rowEnd = dy > 0 ? focusItem->rows - 1 : focusItem->y - 1;
bool_t found = false;
uint16_t bestDistance = 0;
uint8_t bestColDistance = 0;
uint8_t bestX = 0;
uint8_t bestY = 0;
for(int16_t yy = rowStart; yy <= rowEnd; yy++) {
uint8_t rowDistance = dy > 0 ?
(uint8_t)(yy - focusItem->y) : (uint8_t)(focusItem->y - yy);
for(uint8_t xx = 0; xx < menu->columns; xx++) {
uint8_t colDistance = xx > focusItem->x ?
xx - focusItem->x : focusItem->x - xx;
uint16_t distance = rowDistance + colDistance;
if(found && distance > bestDistance) continue;
if(found && distance == bestDistance && colDistance >= bestColDistance) {
continue;
}
uint8_t slot = (uint8_t)yy * menu->columns + xx;
uint8_t index = uiMenuFocusSlotToIndex(menu, slot);
if(index == 0xFF || uiMenuItemIsBlank(&menu->items[index])) continue;
found = true;
bestDistance = distance;
bestColDistance = colDistance;
bestX = xx;
bestY = (uint8_t)yy;
}
}
// Nothing further in that direction at all (already on the edge row) -
// wrap to the opposite edge as a last resort, landing on its nearest
// column. This only ever kicks in when the non-wrapped search above
// found literally nothing, so it can't hijack a real nearby match the
// way wrapping during the main search could.
if(!found && focusItem->rows > 1) {
uint8_t wrapY = dy > 0 ? 0 : focusItem->rows - 1;
for(uint8_t xx = 0; xx < menu->columns; xx++) {
uint8_t colDistance = xx > focusItem->x ?
xx - focusItem->x : focusItem->x - xx;
if(found && colDistance >= bestDistance) continue;
uint8_t slot = wrapY * menu->columns + xx;
uint8_t index = uiMenuFocusSlotToIndex(menu, slot);
if(index == 0xFF || uiMenuItemIsBlank(&menu->items[index])) continue;
found = true;
bestDistance = colDistance;
bestX = xx;
bestY = wrapY;
}
}
if(found) {
uiMenuSetPosition(menu, bestX, bestY);
return true;
}
// Nothing non-blank in that direction at all - fall through to the
// default single-step move.
return false;
}
+25 -6
View File
@@ -271,12 +271,31 @@ bool_t uiMenuFocusDirection(
bool_t uiMenuItemIsBlank(const uimenuitem_t *item);
/**
* Moves focusItem to the next cell in the given direction that isn't
* blank (see uiMenuItemIsBlank), wrapping within that row/column as many
* times as needed - so, from the menu's perspective, a blank cell simply
* isn't there. Gives up (returning false, so the default single-step
* move applies instead) only if every cell along that entire row/column
* is blank.
* Moves focusItem to a non-blank cell (see uiMenuItemIsBlank) in the
* given direction, wrapping the grid as many times as needed - so, from
* the menu's perspective, a blank cell simply isn't there.
*
* LEFT/RIGHT scan straight along the row, since there's no ambiguity
* about which non-blank cell is "next" (wrapping within the row as
* needed). UP/DOWN instead considers every cell strictly further in the
* pressed direction and lands on whichever non-blank cell has the
* smallest combined row+column distance from the one moved from - e.g.
* moving down off a densely-packed row onto a mostly-blank one lands on
* whatever's closest instead of only ever reaching a cell that happens
* to share the exact same column, while an exact column match a couple
* of rows down still wins over an approximate match one row down. Ties
* on total distance favor the smaller column distance, so a straight
* move down onto an exact column match beats an equally-far diagonal
* one. This search never wraps in either axis while there's still a
* real cell further in the pressed direction - column/row 0 is never
* treated as adjacent to the far edge just because the grid wraps in
* general. Only once already on the edge row (nothing further to search
* at all) does it fall back to wrapping to the opposite edge's nearest
* column, so pressing up from the top row still goes somewhere instead
* of doing nothing.
*
* Gives up (returning false, so the default single-step move applies
* instead) only if every cell on the grid is blank.
*
* @param menu The menu to move within.
* @param focusItem The active focus item; must belong to menu.