Files
SUB-OS/gui/gui_app_web.c
T
subhobhai943andClaude Opus 4.8 9f90361265 feat(gui): make the Web app a real browser -- clickable links, history, relative URLs
The Web app fetched and rendered pages but could not browse between them.
Now it does. In Reader view, anchors are parsed as the markup is stripped and
recorded as links spanning the text they enclose; those spans render in the
accent colour with an underline, and a click on one follows it.

  - Link capture: html_to_text now tracks <a href> open/close, storing each
    link's [start,end) span in the processed text plus its href in a per-page
    string pool. Rendering overlays link spans in accent + underline; a body
    click maps pixel -> line -> text offset -> containing link.
  - Relative resolution: a link's href is resolved against the page's own
    address (captured from the fetch's final host/port/path). Root-relative
    (/path), document-relative (page.html) and absolute URLs all work; the
    default port is omitted from the rebuilt URL. mailto:/javascript:/#frag
    are treated as non-navigable.
  - History: a back stack (depth 16) records each address before navigating; a
    Back button, lit only when there is somewhere to return to, pops it.
  - https:// links resolve through and are refused by the fetch layer with the
    existing clear message rather than being silently dropped.

Verified on x86_64 with real synthetic clicks against a small multi-page HTTP
site: the home page rendered its two links highlighted; clicking "Go to Page
Two" navigated (URL bar updated to the resolved http://host/page2.html, host
logged GET /page2.html) and Page Two rendered with its own links; the Back
button returned to the home page (host logged GET /). Builds clean on x86_64,
aarch64 and armv8i.

Known limit: no TLS, so https:// pages are reported not shown -- plain HTTP
browsing only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fh5z6V7nGPbHvqVG5p4CXX
2026-08-27 12:17:59 +05:30

640 lines
24 KiB
C

/*
* SUB-OS Web -- a minimal text web browser.
*
* It is the visible end of the kernel's TCP client: type a URL, and the page
* is fetched over the stack SUB-OS built. The fetch itself is blocking, so it
* runs on the kernel's async HTTP worker thread (net/http_client) rather than
* in the compositor -- the desktop keeps painting at full frame rate while a
* page loads. This window only ever polls a job's state and renders the
* result, so it never stalls.
*
* Two views of the response: Raw shows the status line, headers and body
* exactly as received; Reader strips the markup and reflows the body into
* wrapped text. The rendered line index is rebuilt only when the content, the
* view or the window width changes, so scrolling and idle repaints are cheap.
*/
#include <gui/gui_apps.h>
#include <gui/gui_widgets.h>
#include <gui/gui_gfx.h>
#include <gui/gui_theme.h>
#include <kernel/ktime.h>
#include <mm/kmalloc.h>
#include <lib/string.h>
#include <lib/printf.h>
#include <net/http_client.h>
#include <net/net.h>
#include <drivers/keyboard.h>
#define WEB_FETCH_CAP 16384 // response bytes retained
#define WEB_MAX_LINES 2048 // rendered display lines retained
#define WEB_MAX_LINKS 400 // hyperlinks tracked per page
#define WEB_HREF_POOL 6144 // bytes of href strings per page
#define WEB_HISTORY 16 // back-stack depth
#define WEB_URL_FIELD 1
// A hyperlink: the [start,end) span it occupies in the processed text, and the
// offset of its href string in the pool.
typedef struct {
int start;
int end;
int href;
} web_link_t;
typedef struct {
char url[256];
http_fetch_t* job;
bool reader; // strip markup and reflow
int scroll; // first visible display line
// Rendered text and its line index, cached against the inputs that
// determine them.
char* text; // processed text being displayed
int text_len;
int offsets[WEB_MAX_LINES];
int lengths[WEB_MAX_LINES];
int nlines;
// Links found while stripping markup (reader view only).
web_link_t links[WEB_MAX_LINKS];
int nlinks;
char hrefs[WEB_HREF_POOL];
int href_used;
// The page's own address, used to resolve relative links.
char base_host[128];
uint16_t base_port;
char base_path[256];
// Back stack of previously-visited URLs.
char hist[WEB_HISTORY][256];
int hist_len;
const char* cache_src; // job->buf the cache was built from
int cache_len;
bool cache_reader;
int cache_cpl; // columns the cache was wrapped to
} web_data_t;
// ===========================================================================
// HTML to text
// ===========================================================================
static bool tag_is_break(const char* tag) {
// Block-level tags whose close (or self) should start a new line.
static const char* const brk[] = {
"br", "/p", "p", "/div", "/h1", "/h2", "/h3", "/h4", "/h5", "/h6",
"/li", "/tr", "/ul", "/ol", "/table", "hr", "/title", "/head"
};
for (size_t i = 0; i < sizeof(brk) / sizeof(brk[0]); i++) {
const char* b = brk[i];
size_t n = strlen(b);
// Match the tag name up to a space, '>' or end, case-insensitively.
size_t k = 0;
while (k < n && tag[k]) {
char a = tag[k], c = b[k];
if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a');
if (a != c) break;
k++;
}
if (k == n) {
char after = tag[k];
if (after == '\0' || after == ' ' || after == '>' || after == '/') return true;
}
}
return false;
}
// Decode the handful of entities worth handling; returns the byte, or 0 if the
// entity is unknown (in which case the caller keeps it verbatim).
static char decode_entity(const char* e, int len) {
if (len == 2 && strncmp(e, "lt", 2) == 0) return '<';
if (len == 2 && strncmp(e, "gt", 2) == 0) return '>';
if (len == 3 && strncmp(e, "amp", 3) == 0) return '&';
if (len == 4 && strncmp(e, "nbsp", 4) == 0) return ' ';
if (len == 4 && strncmp(e, "quot", 4) == 0) return '"';
if (len == 4 && strncmp(e, "apos", 4) == 0) return '\'';
if (len == 5 && strncmp(e, "mdash", 5) == 0) return '-';
if (len == 5 && strncmp(e, "ndash", 5) == 0) return '-';
return 0;
}
// Copy the href="..." value out of an anchor tag's body (the text between the
// '<' and its '>') into the page's href pool, and return its pool offset, or -1
// if there is no usable href or the pool is full.
static int capture_href(web_data_t* wd, const char* tag, int taglen) {
// Find "href" (case-insensitive) followed by '='.
int h = -1;
for (int i = 0; i + 4 <= taglen; i++) {
char a = tag[i], b = tag[i + 1], c = tag[i + 2], d = tag[i + 3];
if ((a | 32) == 'h' && (b | 32) == 'r' && (c | 32) == 'e' && (d | 32) == 'f') {
int j = i + 4;
while (j < taglen && (tag[j] == ' ' || tag[j] == '\t')) j++;
if (j < taglen && tag[j] == '=') { h = j + 1; break; }
}
}
if (h < 0) return -1;
while (h < taglen && (tag[h] == ' ' || tag[h] == '\t')) h++;
char quote = 0;
if (h < taglen && (tag[h] == '"' || tag[h] == '\'')) { quote = tag[h]; h++; }
if (wd->href_used >= WEB_HREF_POOL - 1) return -1;
int off = wd->href_used;
int o = off;
while (h < taglen && o < WEB_HREF_POOL - 1) {
char c = tag[h];
if (quote ? (c == quote) : (c == ' ' || c == '\t' || c == '>')) break;
wd->hrefs[o++] = c;
h++;
}
wd->hrefs[o++] = '\0';
if (o == off + 1) return -1; // empty href
wd->href_used = o;
return off;
}
// Strip markup from `src` into wd->text, collapsing whitespace and turning
// block tags into newlines. <script>/<style> bodies are dropped. Anchors are
// recorded as links spanning the text they enclose. Returns the text length.
static int html_to_text(web_data_t* wd, const char* src, int slen) {
char* out = wd->text;
int cap = WEB_FETCH_CAP;
int o = 0;
bool sp_pending = false; // a space owed but not yet emitted
bool at_line_start = true;
bool link_open = false; // inside an <a>...</a>
int link_start = 0;
int link_href = -1;
wd->nlinks = 0;
wd->href_used = 0;
for (int i = 0; i < slen && o < cap - 1; i++) {
char ch = src[i];
if (ch == '<') {
const char* tag = src + i + 1;
// Skip <script>...</script> and <style>...</style> bodies entirely.
if (strncmp(tag, "script", 6) == 0 || strncmp(tag, "style", 5) == 0) {
const char* close = (tag[0] == 's' && tag[1] == 'c') ? "</script" : "</style";
int cl = (int)strlen(close);
int j = i + 1;
while (j < slen && strncmp(src + j, close, cl) != 0) j++;
i = j;
while (i < slen && src[i] != '>') i++;
continue;
}
bool brk = tag_is_break(tag);
bool a_open = (tag[0] | 32) == 'a' &&
(tag[1] == ' ' || tag[1] == '\t' || tag[1] == '>');
bool a_close = tag[0] == '/' && (tag[1] | 32) == 'a' &&
(tag[2] == '>' || tag[2] == ' ');
int tstart = i + 1;
while (i < slen && src[i] != '>') i++; // consume to tag close
int tlen = i - tstart;
if (a_open && !link_open) {
link_href = capture_href(wd, src + tstart, tlen);
link_open = (link_href >= 0);
link_start = o;
} else if (a_close && link_open) {
if (o > link_start && wd->nlinks < WEB_MAX_LINKS) {
wd->links[wd->nlinks].start = link_start;
wd->links[wd->nlinks].end = o;
wd->links[wd->nlinks].href = link_href;
wd->nlinks++;
}
link_open = false;
}
if (brk && !at_line_start) {
out[o++] = '\n';
at_line_start = true;
sp_pending = false;
}
continue;
}
if (ch == '&') {
int j = i + 1;
while (j < slen && src[j] != ';' && j - i < 8) j++;
char d = (j < slen && src[j] == ';') ? decode_entity(src + i + 1, j - i - 1) : 0;
if (d) {
if (sp_pending && !at_line_start) { out[o++] = ' '; sp_pending = false; }
if (o < cap - 1) out[o++] = d;
at_line_start = false;
i = j;
continue;
}
// Unknown entity: fall through and treat '&' as an ordinary char.
}
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
if (!at_line_start) sp_pending = true;
continue;
}
if (sp_pending) { out[o++] = ' '; sp_pending = false; if (o >= cap - 1) break; }
out[o++] = ch;
at_line_start = false;
}
out[o] = '\0';
return o;
}
// ===========================================================================
// Line breaking
// ===========================================================================
static void web_build_lines(web_data_t* wd, int cpl) {
wd->nlines = 0;
if (cpl < 8) cpl = 8;
int n = wd->text_len;
const char* t = wd->text;
int i = 0;
while (i < n && wd->nlines < WEB_MAX_LINES) {
int start = i;
int last_space = -1;
int col = 0;
while (i < n && t[i] != '\n' && col < cpl) {
if (t[i] == ' ') last_space = i;
i++;
col++;
}
int end;
if (i < n && t[i] == '\n') {
end = i;
i++; // consume the newline
} else if (col >= cpl && i < n) {
if (last_space > start) { // wrap at the last space
end = last_space;
i = last_space + 1;
} else {
end = i; // a word longer than a line
}
} else {
end = i; // reached the end of the text
}
wd->offsets[wd->nlines] = start;
wd->lengths[wd->nlines] = end - start;
wd->nlines++;
}
}
// Rebuild the processed text and line index if any input changed.
static void web_refresh_cache(web_data_t* wd, int cpl) {
http_fetch_t* j = wd->job;
if (!j || j->state != HTTP_STATE_DONE) { wd->nlines = 0; return; }
if (wd->cache_src == j->buf && wd->cache_len == j->len &&
wd->cache_reader == wd->reader && wd->cache_cpl == cpl) {
return; // still valid
}
// Remember the page's own address for resolving relative links.
strncpy(wd->base_host, j->host, sizeof(wd->base_host) - 1);
wd->base_host[sizeof(wd->base_host) - 1] = '\0';
strncpy(wd->base_path, j->path, sizeof(wd->base_path) - 1);
wd->base_path[sizeof(wd->base_path) - 1] = '\0';
wd->base_port = j->port;
if (wd->reader) {
// Reader view: skip the headers, then strip markup from the body,
// recording anchors as links as it goes.
const char* body = j->buf;
char* sep = strstr(j->buf, "\r\n\r\n");
if (sep) body = sep + 4;
int blen = j->len - (int)(body - j->buf);
if (blen < 0) blen = 0;
wd->text_len = html_to_text(wd, body, blen);
} else {
// Raw view: the response verbatim, with tabs normalised. No links.
int n = j->len < WEB_FETCH_CAP - 1 ? j->len : WEB_FETCH_CAP - 1;
for (int k = 0; k < n; k++) {
char c = j->buf[k];
wd->text[k] = (c == '\t') ? ' ' : c;
}
wd->text[n] = '\0';
wd->text_len = n;
wd->nlinks = 0;
}
web_build_lines(wd, cpl);
wd->cache_src = j->buf;
wd->cache_len = j->len;
wd->cache_reader = wd->reader;
wd->cache_cpl = cpl;
if (wd->scroll > wd->nlines - 1) wd->scroll = 0;
}
// ===========================================================================
// Fetching and navigation
// ===========================================================================
static void web_go(web_data_t* wd) {
if (wd->url[0] == '\0') return;
// Drop any previous job (releasing a still-running one hands it to the
// worker to free) and invalidate the render cache.
if (wd->job) { http_fetch_release(wd->job); wd->job = NULL; }
wd->cache_src = NULL;
wd->scroll = 0;
wd->job = http_fetch_start(wd->url, WEB_FETCH_CAP);
// A NULL result (worker busy or bad URL) is reflected in the status line
// drawn from the live job state, so nothing more to do here.
}
// Resolve a link's href against the current page into an absolute URL. Returns
// false for links that are not navigable here (fragments, mailto:, javascript:,
// empty). https:// is resolved through and left for the fetch layer to refuse
// with a clear message rather than silently dropped.
static bool web_resolve(web_data_t* wd, const char* href, char* out, size_t cap) {
if (!href || href[0] == '\0' || href[0] == '#') return false;
if (strncmp(href, "mailto:", 7) == 0 || strncmp(href, "javascript:", 11) == 0) {
return false;
}
if (strncmp(href, "http://", 7) == 0 || strncmp(href, "https://", 8) == 0) {
strncpy(out, href, cap - 1);
out[cap - 1] = '\0';
return true;
}
// Port suffix only when it is not the default 80.
char portbuf[8] = "";
if (wd->base_port != 80) snprintf(portbuf, sizeof(portbuf), ":%u", wd->base_port);
if (href[0] == '/') {
// Root-relative: keep host, replace path.
snprintf(out, cap, "http://%s%s%s", wd->base_host, portbuf, href);
return true;
}
// Document-relative: append to the current path's directory.
char dir[256];
strncpy(dir, wd->base_path, sizeof(dir) - 1);
dir[sizeof(dir) - 1] = '\0';
char* slash = strrchr(dir, '/');
if (slash) slash[1] = '\0'; // keep the trailing slash
else strcpy(dir, "/");
snprintf(out, cap, "http://%s%s%s%s", wd->base_host, portbuf, dir, href);
return true;
}
// Follow a link: remember where we are, then load the target.
static void web_navigate(web_data_t* wd, const char* href) {
char target[256];
if (!web_resolve(wd, href, target, sizeof(target))) return;
// Push the current address onto the back stack (dropping the oldest if it
// is full), unless we have not loaded anything yet.
if (wd->url[0]) {
if (wd->hist_len == WEB_HISTORY) {
for (int i = 1; i < WEB_HISTORY; i++) {
strcpy(wd->hist[i - 1], wd->hist[i]);
}
wd->hist_len--;
}
strncpy(wd->hist[wd->hist_len], wd->url, sizeof(wd->hist[0]) - 1);
wd->hist[wd->hist_len][sizeof(wd->hist[0]) - 1] = '\0';
wd->hist_len++;
}
strncpy(wd->url, target, sizeof(wd->url) - 1);
wd->url[sizeof(wd->url) - 1] = '\0';
web_go(wd);
}
static void web_back(web_data_t* wd) {
if (wd->hist_len == 0) return;
wd->hist_len--;
strncpy(wd->url, wd->hist[wd->hist_len], sizeof(wd->url) - 1);
wd->url[sizeof(wd->url) - 1] = '\0';
web_go(wd);
}
// ===========================================================================
// Paint
// ===========================================================================
static void web_paint(gui_window_t* win) {
web_data_t* wd = (web_data_t*)win->user_data;
if (!wd) return;
gui_widget_begin(win);
int w = gui_widget_client_width();
int h = gui_widget_client_height();
int ox = win->x + 1;
int oy = win->y + GUI_TITLEBAR_HEIGHT + 1;
if (w < 240 || h < 120) { gui_widget_end(); return; }
// --- toolbar ----------------------------------------------------------
int back_w = 26, go_w = 34, mode_w = 62;
int field_x = 10 + back_w + 4;
int field_w = w - field_x - 4 - go_w - 4 - mode_w - 8;
if (field_w < 60) field_w = 60;
// Back is enabled only when there is somewhere to go back to.
bool can_back = wd->hist_len > 0;
if (gui_button_colored(6, 10, 8, back_w, 20, "<",
can_back ? GUI_THEME_PRIMARY : GUI_THEME_BORDER) && can_back) {
web_back(wd);
}
if (gui_textfield(WEB_URL_FIELD, field_x, 8, field_w, wd->url, sizeof(wd->url))) {
/* editing; nothing to do until Go */
}
// Enter in the focused URL field triggers the fetch: the field leaves
// newline keys unconsumed, so they are still visible here.
const gui_widget_input_t* in = gui_widget_get_input();
if (in->focus_id == WEB_URL_FIELD && (in->key == '\n' || in->key == '\r')) {
gui_widget_set_focus(0);
web_go(wd);
}
if (gui_button(2, field_x + field_w + 4, 8, go_w, 20, "Go")) web_go(wd);
if (gui_button_colored(3, field_x + field_w + 4 + go_w + 4, 8, mode_w, 20,
wd->reader ? "Reader" : "Raw",
wd->reader ? GUI_THEME_SUCCESS : GUI_THEME_ACCENT)) {
wd->reader = !wd->reader;
wd->cache_src = NULL; // force a re-render in the new view
wd->scroll = 0;
}
// --- status line ------------------------------------------------------
int bar_y = 34;
char status[128];
uint32_t status_col = GUI_THEME_TEXT_MUTED;
if (!wd->job) {
snprintf(status, sizeof(status), "Enter a URL and press Go (e.g. 10.0.2.2:8000/)");
} else if (wd->job->state == HTTP_STATE_RUNNING) {
// A small spinner off the 10 Hz repaint so a slow fetch looks alive.
static const char frames[] = { '|', '/', '-', '\\' };
char sp = frames[(ktime_ms() / 120) % 4];
snprintf(status, sizeof(status), "%c fetching %s ...", sp, wd->job->host);
status_col = GUI_THEME_WARNING;
} else if (wd->job->state == HTTP_STATE_ERROR) {
snprintf(status, sizeof(status), "error: %s", wd->job->err);
status_col = GUI_THEME_DANGER;
} else {
char ips[16];
ip_to_str(wd->job->ip, ips);
snprintf(status, sizeof(status), "HTTP %d %d bytes %u ms %s:%u (%s)",
wd->job->status_code, wd->job->len, wd->job->elapsed_ms,
wd->job->host, wd->job->port, ips);
status_col = (wd->job->status_code == 200) ? GUI_THEME_SUCCESS : GUI_THEME_TEXT_MAIN;
}
gui_label(10, bar_y, status, status_col);
gui_separator(10, bar_y + 14, w - 20);
// --- page body --------------------------------------------------------
int view_x = 10;
int view_y = bar_y + 20;
int view_h = h - view_y - 6;
int line_h = 10;
int cpl = (w - 20 - 12) / GUI_FONT_W; // leave room for the scrollbar
if (cpl < 8) cpl = 8;
int rows = view_h / line_h;
if (rows < 1) rows = 1;
gui_gfx_fill_rect(ox + view_x, oy + view_y, w - 20, view_h, GUI_THEME_BG_DARK);
gui_gfx_draw_rect(ox + view_x, oy + view_y, w - 20, view_h, GUI_THEME_BORDER);
web_refresh_cache(wd, cpl);
if (wd->job && wd->job->state == HTTP_STATE_DONE && wd->nlines > 0) {
if (wd->scroll > wd->nlines - 1) wd->scroll = wd->nlines - 1;
if (wd->scroll < 0) wd->scroll = 0;
// A click on a link's text follows it. Map the click to a text offset,
// then to the link whose span contains it. Done before drawing so a
// navigation takes hold immediately.
if (wd->reader && wd->nlinks && in->mouse_clicked) {
int rx = in->mouse_x - (view_x + 6);
int ry = in->mouse_y - (view_y + 5);
if (rx >= 0 && ry >= 0 && in->mouse_x < view_x + (w - 20) - 4 &&
in->mouse_y < view_y + view_h) {
int idx = wd->scroll + ry / line_h;
int col = rx / GUI_FONT_W;
if (idx >= 0 && idx < wd->nlines && col < wd->lengths[idx]) {
int off = wd->offsets[idx] + col;
for (int li = 0; li < wd->nlinks; li++) {
if (off >= wd->links[li].start && off < wd->links[li].end) {
web_navigate(wd, wd->hrefs + wd->links[li].href);
break;
}
}
}
}
}
char line[200];
for (int r = 0; r < rows; r++) {
int idx = wd->scroll + r;
if (idx >= wd->nlines) break;
int loff = wd->offsets[idx];
int len = wd->lengths[idx];
if (len > (int)sizeof(line) - 1) len = (int)sizeof(line) - 1;
memcpy(line, wd->text + loff, len);
line[len] = '\0';
int sx = ox + view_x + 6;
int sy = oy + view_y + 5 + r * line_h;
// In raw view the header block (up to the blank line) is dimmed so
// the body stands out; reader view is uniform.
uint32_t col = GUI_THEME_TEXT_MAIN;
if (!wd->reader && len == 0) col = GUI_THEME_TEXT_DIM;
gui_gfx_draw_string(sx, sy, line, col);
// Overlay any link spans on this line in the accent colour, with an
// underline, so hyperlinks read as clickable.
if (wd->reader) {
for (int li = 0; li < wd->nlinks; li++) {
int s = wd->links[li].start, e = wd->links[li].end;
if (e <= loff || s >= loff + len) continue;
int a = s > loff ? s : loff;
int b = e < loff + len ? e : loff + len;
int seglen = b - a;
if (seglen <= 0 || seglen > (int)sizeof(line) - 1) continue;
char seg[200];
memcpy(seg, wd->text + a, seglen);
seg[seglen] = '\0';
int segx = sx + (a - loff) * GUI_FONT_W;
gui_gfx_draw_string(segx, sy, seg, GUI_THEME_ACCENT);
gui_gfx_fill_rect(segx, sy + 9, seglen * GUI_FONT_W, 1, GUI_THEME_ACCENT);
}
}
}
// Scrollbar and keyboard paging.
gui_scrollbar(4, w - 18, view_y, view_h, &wd->scroll, wd->nlines, rows);
if (in->focus_id != WEB_URL_FIELD) {
if (in->key == KEY_DOWN) wd->scroll += 1;
else if (in->key == KEY_UP) wd->scroll -= 1;
else if (in->key == KEY_PAGE_DOWN) wd->scroll += rows;
else if (in->key == KEY_PAGE_UP) wd->scroll -= rows;
}
// A link count hint, so it is clear the page is navigable.
if (wd->reader && wd->nlinks) {
char hint[48];
snprintf(hint, sizeof(hint), "%d link%s - click to follow",
wd->nlinks, wd->nlinks == 1 ? "" : "s");
gui_label_aligned(0, bar_y, w - 12, hint, GUI_THEME_ACCENT, GUI_ALIGN_RIGHT);
}
} else if (wd->job && wd->job->state == HTTP_STATE_RUNNING) {
gui_gfx_draw_string(ox + view_x + 8, oy + view_y + 10,
"Loading over TCP -- the desktop stays responsive.",
GUI_THEME_TEXT_DIM);
}
gui_widget_end();
}
static void web_event(gui_window_t* win, const gui_event_t* ev) {
gui_widget_feed_event(win, ev);
if (ev->type == GUI_EVENT_CLOSE && win->user_data) {
web_data_t* wd = (web_data_t*)win->user_data;
if (wd->job) http_fetch_release(wd->job); // safe mid-fetch
if (wd->text) kfree(wd->text);
kfree(wd);
win->user_data = NULL;
}
}
void gui_app_web_launch(int x, int y, int w, int h) {
gui_window_t* win = gui_wm_create_window("Web", x, y,
(w > 0) ? w : 600, (h > 0) ? h : 420);
if (!win) return;
web_data_t* wd = (web_data_t*)kzalloc(sizeof(web_data_t));
if (!wd) { gui_wm_destroy_window(win->id); return; }
wd->text = (char*)kzalloc(WEB_FETCH_CAP);
if (!wd->text) { kfree(wd); gui_wm_destroy_window(win->id); return; }
wd->reader = true;
strcpy(wd->url, "10.0.2.2:8000/"); // the host, reachable under QEMU slirp
win->user_data = wd;
win->paint = web_paint;
win->handle_event = web_event;
}