SchOS/boot/common/ui.c
Nexoniarz 799abbc19a boot: one UI for both firmwares, and a metal look
The BIOS loader drew its own splash and the UEFI loader drew a different
one, which is how two implementations of a single screen end up looking
nothing alike: only one of them ever got a change. stage2main.c's
fourteen drawing functions are deleted and it calls boot/common/ui.c,
which is what the UEFI loader already used. There is one splash now.

What changed in the look, in order of how much it mattered:

  - No product name under the mark. The name is *in* the mark, and
    setting it again underneath read as a placeholder nobody removed.
  - The mark is scaled to the bar's width and sits directly above it, so
    the two are one object rather than a large logo with an unrelated
    line beneath. Box-filtered down from the source mask, never up:
    point-sampling a coverage mask drops half the edge pixels and puts
    holes in the thin strokes.
  - Metal rather than flat grey. A vertical gradient with a knee near the
    top where a highlight falls, applied to the mark, the bar fill and
    panel headings. A single colour on a dark ground looks printed; the
    knee is what makes it look lit. No texture -- at this size noise
    reads as dirt.
  - The boot manager is a panel with a lit selected row, replacing a
    screen laid out in literals with a magic y of 340 that only ever
    looked right at 800x600.

Three defects found by reading pixels rather than code:

  - the UEFI loader kept calling print() after the splash, and ConOut on
    a graphical firmware renders into the same framebuffer -- a
    screenshot showed firmware text across our mark from the left edge.
    Diagnostics go to COM1 directly now, which is where they were being
    read from anyway, and the console is cleared before drawing so the
    firmware has nothing to put back.
  - SPLASH_UP was declared and read but never assigned, so that redirect
    was dead code and the "serial works" reading was OVMF's own console.
  - stage2 overflowed its allocation -- 25440 bytes against 24576 -- and
    the build did not stop. The kernel is written at sector
    1 + STAGE2_SECTORS, so a stage2 past its allocation is overwritten by
    the kernel it is loading. STAGE2_SECTORS is 64 now, and the padding
    tool's check does fire; it had simply not been re-run.

Verified from the host on both: BIOS 800x600 and UEFI 1280x800 both draw
a centred group with zero stray pixels, UEFI's spanning x 576-703 which
is exactly the mark width centred. bootcheck 108 [ok], iso builds.

Still text-mode: the resolution menu. On BIOS it necessarily is -- it
runs in real mode before any framebuffer exists, which is the whole
reason it can set one. On UEFI it could be graphical and is not yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 08:49:44 +02:00

464 lines
14 KiB
C

/* SPDX-License-Identifier: Apache-2.0 */
/*
* The loader's user interface. See ui.h for why this can be shared at all.
*
* The look is deliberately quiet: a dark ground, the mark centred, one thin
* bar beneath it, and no text that is not load-bearing. A loader is not a
* place to be interesting -- it is looked at for four seconds by someone who
* wants to be somewhere else, and the useful thing it can do is stay still and
* tell them it is working.
*/
#include "ui.h"
#include "../../include/theme.h"
#include "../../include/font.h"
#include "../../include/logo.h"
/* "Draw the glyph, leave the background alone." Not a colour anyone can mean:
* the palette is 24-bit, so the high byte is free for a sentinel. */
#define SCH_BG_NONE 0xFF000000u
static struct ui_fb FB;
static int ACTIVE;
/* Progress state. Kept here rather than passed around because the caller ticks
* it from inside a read loop that has no business carrying UI state. */
static ui_u32 PROG_TOTAL, PROG_DONE;
static int PROG_X, PROG_Y, PROG_W, PROG_H;
static int PROG_LAST_PX; /* pixels already filled */
static int PROG_ACTIVE;
/* Layout constants. One place, because a splash that is laid out by scattered
* literals is one nobody dares move.
*
* The mark and the bar are the same width, and that is the arrangement: a small
* mark with a short bar directly beneath it, both centred. Everything else --
* gaps, panel sizes -- is expressed against that width so the whole thing
* scales as one object.
*/
#define MARK_W_FRAC 8 /* the mark is width/8 ... */
#define MARK_W_MIN 72 /* ... clamped, because a mark that tracks the */
#define MARK_W_MAX 128 /* mode exactly is tiny at 800 and soft at 1600 */
#define MARK_GAP 30 /* mark bottom to the bar */
#define SUB_GAP 20 /* bar to the line under it */
#define BAR_H 4 /* thin: it is a hint, not a gauge */
#define GLYPH_W 8
#define GLYPH_H 16
/* --- the metal ------------------------------------------------------------
*
* Brushed metal, done the cheap way that actually survives a 4-bit mask and a
* 32bpp framebuffer: a vertical gradient from a light top to a darker bottom,
* with a brighter band near the top where a highlight would fall. There is no
* texture and no noise -- at this size they read as dirt rather than as metal,
* and the loader has no business carrying a texture asset.
*
* The mark is not a flat colour any more for the same reason a real one would
* not be: a single grey on a dark ground looks printed, and the gradient is
* what makes it look like an object with a light above it.
*/
#define METAL_TOP 0xF2F4F8u /* near-white, where the light lands */
#define METAL_MID 0xB9BEC7u
#define METAL_BOT 0x6E747Fu /* shadowed underside */
#define METAL_EDGE 0x3A3F49u /* panel border */
#define PANEL_BG 0x151821u /* panel ground, a shade off SCH_BG */
#define PANEL_TOP 0x232838u /* bevel: one row lighter along the top */
static ui_u32
mix(ui_u32 a, ui_u32 b, int num, int den)
{
if (den <= 0) return a;
if (num < 0) num = 0;
if (num > den) num = den;
ui_u32 r = ((((a >> 16) & 0xFF) * (den - num)) + (((b >> 16) & 0xFF) * num)) / den;
ui_u32 g = ((((a >> 8) & 0xFF) * (den - num)) + (((b >> 8) & 0xFF) * num)) / den;
ui_u32 bl = ((((a ) & 0xFF) * (den - num)) + (((b ) & 0xFF) * num)) / den;
return (r << 16) | (g << 8) | bl;
}
/* The metal colour at row `i` of an object `h` rows tall. */
static ui_u32
metal(int i, int h)
{
if (h <= 1)
return METAL_MID;
/* Two segments: a fast lift into the highlight over the top third, then a
* long fall to the shadow. A single linear ramp looks like a gradient; the
* knee is what makes it look lit. */
int knee = h / 3;
if (i <= knee)
return mix(METAL_MID, METAL_TOP, i, knee ? knee : 1);
return mix(METAL_TOP, METAL_BOT, i - knee, h - knee);
}
void
ui_init(const struct ui_fb *fb)
{
ACTIVE = 0;
if (!fb || !fb->base || !fb->width || !fb->height)
return;
FB = *fb;
if (!FB.stride)
FB.stride = FB.width * 4;
ACTIVE = 1;
}
int ui_active(void) { return ACTIVE; }
ui_u32 ui_width(void) { return ACTIVE ? FB.width : 0; }
ui_u32 ui_height(void) { return ACTIVE ? FB.height : 0; }
static void
put(int x, int y, ui_u32 colour)
{
if (!ACTIVE || x < 0 || y < 0 || (ui_u32)x >= FB.width || (ui_u32)y >= FB.height)
return;
*(ui_u32 *)(FB.base + (ui_u64)y * FB.stride + (ui_u64)x * 4) = colour;
}
void
ui_fill_rect(int x, int y, int w, int h, ui_u32 colour)
{
if (!ACTIVE)
return;
for (int r = 0; r < h; r++) {
int yy = y + r;
if (yy < 0 || (ui_u32)yy >= FB.height)
continue;
/* One row at a time through a row pointer: put() per pixel costs a
* bounds check and a multiply for every one of them, and this is used
* to clear a whole screen. */
ui_u32 *row = (ui_u32 *)(FB.base + (ui_u64)yy * FB.stride);
for (int c = 0; c < w; c++) {
int xx = x + c;
if (xx >= 0 && (ui_u32)xx < FB.width)
row[xx] = colour;
}
}
}
void
ui_clear(void)
{
ui_fill_rect(0, 0, (int)FB.width, (int)FB.height, SCH_BG);
}
static void
glyph(int x, int y, ui_u8 c, ui_u32 colour, ui_u32 bg)
{
for (int row = 0; row < GLYPH_H; row++) {
ui_u8 bits = font_data[c][row];
for (int col = 0; col < GLYPH_W; col++) {
if (bits & (0x80 >> col))
put(x + col, y + row, colour);
else if (bg != SCH_BG_NONE)
put(x + col, y + row, bg);
}
}
}
void
ui_text(int x, int y, const char *s, ui_u32 colour, ui_u32 bg)
{
if (!ACTIVE || !s)
return;
for (int i = 0; s[i]; i++)
glyph(x + i * GLYPH_W, y, (ui_u8)s[i], colour, bg);
}
static int
slen(const char *s)
{
int n = 0;
while (s && s[n])
n++;
return n;
}
void
ui_text_centered(int y, const char *s, ui_u32 colour)
{
if (!ACTIVE)
return;
int w = slen(s) * GLYPH_W;
ui_text(((int)FB.width - w) / 2, y, s, colour, SCH_BG_NONE);
}
int
ui_mark_width(void)
{
if (!ACTIVE)
return MARK_W_MIN;
int w = (int)FB.width / MARK_W_FRAC;
if (w < MARK_W_MIN) w = MARK_W_MIN;
if (w > MARK_W_MAX) w = MARK_W_MAX;
return w;
}
void
ui_logo(int y, int w)
{
if (!ACTIVE)
return;
if (w <= 0 || w > LOGO_W)
w = LOGO_W; /* never upscale: it only goes soft */
int h = (LOGO_H * w) / LOGO_W;
int x0 = ((int)FB.width - w) / 2;
/* Box-filter down from the source mask.
*
* Point-sampling a coverage mask drops half the edge pixels and gives a
* mark with holes in the thin strokes -- exactly where a logo can least
* afford them. Averaging the coverage of every source pixel that lands in a
* destination pixel is what keeps the shape intact at 72 px.
*/
for (int dy = 0; dy < h; dy++) {
int sy0 = (dy * LOGO_H) / h, sy1 = ((dy + 1) * LOGO_H) / h;
if (sy1 <= sy0) sy1 = sy0 + 1;
ui_u32 shade = metal(dy, h);
for (int dx = 0; dx < w; dx++) {
int sx0 = (dx * LOGO_W) / w, sx1 = ((dx + 1) * LOGO_W) / w;
if (sx1 <= sx0) sx1 = sx0 + 1;
unsigned sum = 0, n = 0;
for (int sy = sy0; sy < sy1; sy++) {
for (int sx = sx0; sx < sx1; sx++) {
unsigned idx = (unsigned)sy * LOGO_W + (unsigned)sx;
sum += (logo_mask[idx >> 1] >> ((idx & 1) ? 0 : 4)) & 0xF;
n++;
}
}
unsigned cov = n ? sum / n : 0;
if (!cov)
continue;
/* Coverage blends the shaded metal against the ground, so the mark
* has soft edges and a lit face at the same time. */
put(x0 + dx, y + dy, mix(SCH_BG, shade, (int)cov, 15));
}
}
}
/* Where the bar goes. Derived, not stored, so nothing can set it and then have
* the screen change underneath it.
*
* The bar is exactly the mark's width and sits directly beneath it: the two are
* one object, and a bar wider than the mark it belongs to reads as a separate
* thing that happens to be nearby.
*/
static void
bar_geometry(void)
{
PROG_W = ui_mark_width();
PROG_H = BAR_H;
PROG_X = ((int)FB.width - PROG_W) / 2;
int mark_h = (LOGO_H * PROG_W) / LOGO_W;
int group_h = mark_h + MARK_GAP + PROG_H;
int top = ((int)FB.height - group_h) / 2;
PROG_Y = top + mark_h + MARK_GAP;
}
void
ui_splash(const char *subtitle)
{
if (!ACTIVE)
return;
ui_clear();
bar_geometry();
int w = PROG_W;
int mark_h = (LOGO_H * w) / LOGO_W;
int top = PROG_Y - MARK_GAP - mark_h;
ui_logo(top, w);
if (subtitle)
ui_text_centered(PROG_Y + PROG_H + SUB_GAP, subtitle, SCH_DIM);
}
/* --- panels ---------------------------------------------------------------
*
* A panel is a plate: a slightly lighter ground than the screen, one lighter
* row along the top and a darker border, which is the whole of the bevel. Two
* rows of shading do more for the impression of a raised surface than any
* amount of gradient inside it, and cost nothing to draw.
*/
static void
panel(int x, int y, int w, int h)
{
ui_fill_rect(x, y, w, h, PANEL_BG);
ui_fill_rect(x, y, w, 1, PANEL_TOP); /* lit top edge */
ui_fill_rect(x, y + h - 1, w, 1, METAL_EDGE); /* shadowed bottom */
ui_fill_rect(x, y, 1, h, METAL_EDGE);
ui_fill_rect(x + w - 1, y, 1, h, METAL_EDGE);
}
/* A heading in metal: the same shading the mark uses, applied per row of the
* glyph cell, so a title looks struck out of the same material. */
static void
metal_text_centered(int y, const char *s)
{
int len = slen(s);
int x = ((int)FB.width - len * GLYPH_W) / 2;
for (int row = 0; row < GLYPH_H; row++) {
ui_u32 shade = metal(row, GLYPH_H);
for (int i = 0; i < len; i++) {
ui_u8 bits = font_data[(ui_u8)s[i]][row];
for (int col = 0; col < GLYPH_W; col++)
if (bits & (0x80 >> col))
put(x + i * GLYPH_W + col, y + row, shade);
}
}
}
#define ROW_H 22
#define PANEL_PAD 18
#define TITLE_H 34
static void
panel_frame(const char *title, int rows, const char *hint,
int *out_x, int *out_y, int *out_w)
{
int w = (int)FB.width / 2;
if (w < 360) w = 360;
if (w > (int)FB.width - 60) w = (int)FB.width - 60;
int h = TITLE_H + rows * ROW_H + PANEL_PAD * 2;
int x = ((int)FB.width - w) / 2;
int y = ((int)FB.height - h) / 2;
ui_clear();
/* The mark, small, above the plate: it is the same screen, not a new one. */
int mw = ui_mark_width() * 2 / 3;
int mh = (LOGO_H * mw) / LOGO_W;
if (y - mh - 24 > 0)
ui_logo(y - mh - 24, mw);
panel(x, y, w, h);
metal_text_centered(y + PANEL_PAD, title);
ui_fill_rect(x + PANEL_PAD, y + TITLE_H - 6, w - PANEL_PAD * 2, 1, METAL_EDGE);
if (hint)
ui_text_centered(y + h + 16, hint, SCH_DIM);
*out_x = x; *out_y = y + TITLE_H; *out_w = w;
}
void
ui_menu(const char *title, const char *const *items, int n, int selected,
const char *hint)
{
if (!ACTIVE)
return;
int x, y, w;
panel_frame(title, n, hint, &x, &y, &w);
for (int i = 0; i < n; i++) {
int ry = y + i * ROW_H;
if (i == selected) {
/* The selected row is the only lit thing on the plate. Filling it
* with the accent and putting the label in the ground colour reads
* at a glance; an outline does not, at this size. */
ui_fill_rect(x + PANEL_PAD / 2, ry - 3, w - PANEL_PAD, ROW_H,
SCH_ACCENT);
ui_text(x + PANEL_PAD, ry, items[i], PANEL_BG, SCH_BG_NONE);
} else {
ui_text(x + PANEL_PAD, ry, items[i], SCH_FG, SCH_BG_NONE);
}
}
}
void
ui_panel(const char *title, const char *const *labels,
const char *const *values, int n, const char *hint)
{
if (!ACTIVE)
return;
int x, y, w;
panel_frame(title, n, hint, &x, &y, &w);
for (int i = 0; i < n; i++) {
int ry = y + i * ROW_H;
ui_text(x + PANEL_PAD, ry, labels[i], SCH_DIM, SCH_BG_NONE);
/* Values right-aligned, so the column reads down rather than ragged. */
int vw = slen(values[i]) * GLYPH_W;
ui_text(x + w - PANEL_PAD - vw, ry, values[i], SCH_FG, SCH_BG_NONE);
}
}
void
ui_progress_begin(const char *label, ui_u32 total)
{
if (!ACTIVE)
return;
bar_geometry();
PROG_TOTAL = total ? total : 1;
PROG_DONE = 0;
PROG_LAST_PX = 0;
PROG_ACTIVE = 1;
/* The unfilled track: a recess, not a colour. Drawn once -- repainting it
* on every tick is what makes a progress bar flicker. */
ui_fill_rect(PROG_X, PROG_Y, PROG_W, PROG_H, 0x1B1F28u);
ui_fill_rect(PROG_X, PROG_Y, PROG_W, 1, 0x101319u);
if (label)
ui_progress_label(label);
}
void
ui_progress_label(const char *label)
{
if (!ACTIVE || !label)
return;
/* Clear the line first. A shorter label after a longer one would otherwise
* leave the tail of the old one behind. */
int y = PROG_Y + PROG_H + SUB_GAP;
ui_fill_rect(0, y, (int)FB.width, GLYPH_H, SCH_BG);
ui_text_centered(y, label, SCH_DIM);
}
void
ui_progress_tick(ui_u32 done)
{
if (!ACTIVE || !PROG_ACTIVE)
return;
if (done > PROG_TOTAL)
done = PROG_TOTAL;
PROG_DONE = done;
int px = (int)(((ui_u64)PROG_DONE * (ui_u64)PROG_W) / PROG_TOTAL);
/* Only the newly filled part. Filling from zero each time is O(width) per
* tick and visibly slower than the thing it is reporting on. */
if (px > PROG_LAST_PX) {
/* The fill is metal too, shaded across the bar's height, so it looks
* like the same material as the mark rather than a coloured rectangle
* that happens to be underneath it. */
for (int r = 0; r < PROG_H; r++)
ui_fill_rect(PROG_X + PROG_LAST_PX, PROG_Y + r,
px - PROG_LAST_PX, 1, metal(r, PROG_H));
PROG_LAST_PX = px;
}
}
void
ui_progress_end(void)
{
if (!ACTIVE || !PROG_ACTIVE)
return;
ui_progress_tick(PROG_TOTAL);
PROG_ACTIVE = 0;
}