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
This commit is contained in:
subhobhai943andClaude Opus 4.8 committed 2026-08-27 12:17:59 +05:30
1 parent f6e4511047
commit 9f90361265
1 file changed
+251 -20
+251 -20
View File
@@ -28,21 +28,48 @@
#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 line index, cached against the inputs that determine it.
// 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;
@@ -91,14 +118,58 @@ static char decode_entity(const char* e, int len) {
return 0;
}
// Strip markup from `src` into `out` (bounded by cap), collapsing runs of
// whitespace and turning block tags into single newlines. <script>/<style>
// contents are dropped wholesale. Returns the output length.
static int html_to_text(const char* src, int slen, char* out, int cap) {
// 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];
@@ -117,7 +188,28 @@ static int html_to_text(const char* src, int slen, char* out, int cap) {
}
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';
@@ -208,16 +300,24 @@ static void web_refresh_cache(web_data_t* wd, int 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.
// 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(body, blen, wd->text, WEB_FETCH_CAP);
wd->text_len = html_to_text(wd, body, blen);
} else {
// Raw view: the response verbatim, with tabs normalised.
// 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];
@@ -225,6 +325,7 @@ static void web_refresh_cache(web_data_t* wd, int cpl) {
}
wd->text[n] = '\0';
wd->text_len = n;
wd->nlinks = 0;
}
web_build_lines(wd, cpl);
@@ -237,7 +338,7 @@ static void web_refresh_cache(web_data_t* wd, int cpl) {
}
// ===========================================================================
// Fetching
// Fetching and navigation
// ===========================================================================
static void web_go(web_data_t* wd) {
if (wd->url[0] == '\0') return;
@@ -253,6 +354,75 @@ static void web_go(web_data_t* wd) {
// 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
// ===========================================================================
@@ -268,9 +438,19 @@ static void web_paint(gui_window_t* win) {
if (w < 240 || h < 120) { gui_widget_end(); return; }
// --- toolbar ----------------------------------------------------------
int go_w = 40, mode_w = 68;
int field_w = w - 20 - go_w - mode_w - 12;
if (gui_textfield(WEB_URL_FIELD, 10, 8, field_w, wd->url, sizeof(wd->url))) {
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 */
}
@@ -282,9 +462,9 @@ static void web_paint(gui_window_t* win) {
web_go(wd);
}
if (gui_button(2, 10 + field_w + 4, 8, go_w, 20, "Go")) 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, 10 + field_w + 4 + go_w + 4, 8, mode_w, 20,
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;
@@ -338,24 +518,67 @@ static void web_paint(gui_window_t* win) {
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 len = wd->lengths[idx];
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 + wd->offsets[idx], len);
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 && idx < wd->nlines) {
if (len == 0) col = GUI_THEME_TEXT_DIM;
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);
}
}
gui_gfx_draw_string(ox + view_x + 6, oy + view_y + 5 + r * line_h,
line, col);
}
// Scrollbar and keyboard paging.
@@ -367,6 +590,14 @@ static void web_paint(gui_window_t* win) {
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.",