╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │ 7 const xitui = xit.xitui; │ │ 8 const wgt = xitui.widget; │ │ 9 const Focus = xitui.focus.Focus; │ │10 +const Grid = xitui.grid.Grid; │ │11 │ │12 const cookie_name = "haxy_user"; │ │13 // flash cookie for surfacing the outcome of the most recent /login POST.│ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │367 err.flush() catch {}; │ │368 } │ │369 │ │369 -// emits the TUI grid cells (one <span> run per focusable widget) │ │370 +// emits the TUI grid cells as static HTML │ │371 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 {│ │372 const grid = root.getGrid() orelse return error.MissingGrid; │ │373 const root_focus = root.getFocus(); │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────╮ │374 │ │375 const Tag = union(enum) { │ │375 - span, │ │376 - a: []const u8, │ │376 + span, // clickable focusable cell │ │377 + a: []const u8, // clickable focusable link│ │378 + plain, // non-focusable, colored │ │379 │ │380 const a_prefix = "a:"; │ │381 │ ╰───────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │386 return .span; │ │387 } │ │388 │ │387 - fn writeOpenTag(self: @This(), alloc: std.mem.Allocator, out: *std.ArrayList(u8), id: usize) !void { │ │389 + fn writeOpenTag(self: @This(), alloc: std.mem.Allocator, out: *std.ArrayList(u8), id: usize, style_attr: []const u8) !void {│ │390 + var id_buf: [32]u8 = undefined; │ │391 switch (self) { │ │392 .span => { │ │390 - var buf: [128]u8 = undefined; │ │391 - const open = try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{d}\">", .{id}); │ │392 - try out.appendSlice(alloc, open); │ │393 + try out.appendSlice(alloc, "<span class=\"clickable\" data-focus-id=\""); │ │394 + try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); │ │395 + try out.appendSlice(alloc, "\""); │ │396 + try out.appendSlice(alloc, style_attr); │ │397 + try out.appendSlice(alloc, ">"); │ │398 }, │ │399 .a => |href| { │ │400 try out.appendSlice(alloc, "<a class=\"clickable\" data-focus-id=\""); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │396 - var id_buf: [32]u8 = undefined; │ │401 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id}));│ │402 try out.appendSlice(alloc, "\" href=\""); │ │403 try appendEscapedHtml(alloc, out, href); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────╮ │400 - try out.appendSlice(alloc, "\">"); │ │404 + try out.appendSlice(alloc, "\""); │ │405 + try out.appendSlice(alloc, style_attr);│ │406 + try out.appendSlice(alloc, ">"); │ │407 + }, │ │408 + .plain => { │ │409 + try out.appendSlice(alloc, "<span"); │ │410 + try out.appendSlice(alloc, style_attr);│ │411 + try out.appendSlice(alloc, ">"); │ │412 }, │ │413 } │ │414 } │ ╰────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────╮ │415 │ │416 fn closeTag(self: @This()) []const u8 {│ │417 return switch (self) { │ │407 - .span => "</span>", │ │418 + .span, .plain => "</span>", │ │419 .a => "</a>", │ │420 }; │ │421 } │ ╰────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │425 errdefer out.deinit(allocator); │ │426 │ │427 for (0..grid.size.height) |y| { │ │417 - var current_id_maybe: ?usize = null; │ │418 - var current_tag_maybe: ?Tag = null; │ │428 + var cur_id: ?usize = null; │ │429 + var cur_fg: ?Grid.Color = null; │ │430 + var cur_bg: ?Grid.Color = null; │ │431 + var open_tag: ?Tag = null; │ │432 + var first = true; │ │433 for (0..grid.size.width) |x| { │ │434 + const cell = grid.cells.items[try grid.cells.at(.{ y, x })]; │ │435 const cell_id = cellFocusId(root_focus, x, y); │ │421 - if (cell_id != current_id_maybe) { │ │422 - if (current_tag_maybe) |current_tag| try out.appendSlice(allocator, current_tag.closeTag());│ │423 - current_tag_maybe = null; │ │436 + const fg = cell.style.fg; │ │437 + const bg = cell.style.bg; │ │438 + │ │439 + if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) { │ │440 + if (open_tag) |t| try out.appendSlice(allocator, t.closeTag()); │ │441 │ │442 + var new_tag: ?Tag = null; │ │443 if (cell_id) |id| { │ │426 - current_tag_maybe = if (root_focus.children.get(id)) |child| switch (child.focus.kind) {│ │427 - .custom => |custom| .init(custom), │ │428 - else => .span, │ │429 - } else null; │ │430 - │ │431 - if (current_tag_maybe) |current_tag| { │ │432 - try current_tag.writeOpenTag(allocator, &out, id); │ │444 + if (root_focus.children.get(id)) |child| { │ │445 + new_tag = switch (child.focus.kind) { │ │446 + .custom => |custom| Tag.init(custom), │ │447 + else => .span, │ │448 + }; │ │449 } │ │450 } │ │435 - current_id_maybe = cell_id; │ │451 + if (new_tag == null and (fg != null or bg != null)) new_tag = .plain; │ │452 + │ │453 + if (new_tag) |t| { │ │454 + var style_buf: [64]u8 = undefined; │ │455 + try t.writeOpenTag(allocator, &out, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); │ │456 + } │ │457 + │ │458 + open_tag = new_tag; │ │459 + cur_id = cell_id; │ │460 + cur_fg = fg; │ │461 + cur_bg = bg; │ │462 + first = false; │ │463 } │ │437 - const rune = grid.cells.items[try grid.cells.at(.{ y, x })].rune orelse " "; │ │438 - try appendEscapedHtml(allocator, &out, rune); │ │464 + │ │465 + try appendEscapedHtml(allocator, &out, cell.rune orelse " "); │ │466 } │ │440 - if (current_tag_maybe) |current_tag| try out.appendSlice(allocator, current_tag.closeTag()); │ │467 + if (open_tag) |t| try out.appendSlice(allocator, t.closeTag()); │ │468 try out.append(allocator, '\n'); │ │469 } │ │470 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │557 } │ │558 │ │559 return try out.toOwnedSlice(allocator); │ │560 +} │ │561 + │ │562 +fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { │ │563 + if (a) |av| return if (b) |bv| av.eql(bv) else false; │ │564 + return b == null; │ │565 +} │ │566 + │ │567 +// builds an HTML ` style="color:#rrggbb;background-color:#rrggbb"` attribute │ │568 +// from a cell's fg/bg into `buf`, omitting whichever color is unset. returns an │ │569 +// empty slice when neither is set. │ │570 +fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color) []const u8 { │ │571 + if (fg == null and bg == null) return buf[0..0]; │ │572 + const prefix = " style=\""; │ │573 + @memcpy(buf[0..prefix.len], prefix); │ │574 + var i: usize = prefix.len; │ │575 + if (fg) |c| { │ │576 + const s = std.fmt.bufPrint(buf[i..], "color:#{x:0>2}{x:0>2}{x:0>2};", .{ c.r, c.g, c.b }) catch return buf[0..0]; │ │577 + i += s.len; │ │578 + } │ │579 + if (bg) |c| { │ │580 + const s = std.fmt.bufPrint(buf[i..], "background-color:#{x:0>2}{x:0>2}{x:0>2};", .{ c.r, c.g, c.b }) catch return buf[0..0];│ │581 + i += s.len; │ │582 + } │ │583 + buf[i] = '"'; │ │584 + i += 1; │ │585 + return buf[0..i]; │ │586 } │ │587 │ │588 fn cellFocusId(focus: *Focus, x: usize, y: usize) ?usize { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯