DVUI

renderText

Only renders a single line of text. Newlines are rendered as spaces.

Selection will be colored with the current themes accent color, with the text color being set to the themes fill color.

Only valid between Window.beginand Window.end.

Parameters

#
opts:TextOptions
TextOptions

Source

Implementation

#
pub fn renderText(opts: TextOptions) Backend.GenericError!void {
    var cw = dvui.currentWindow();
    // Record character heights and positions for AccessKit text_run role.
    var text_info: std.MultiArrayList(AccessKit.CharPositionInfo) = .empty;
    const clipped_rect = dvui.clipGet().intersect(opts.rs.r);

    // If accessibility is enabled, we still need create the associated text_run
    // even when the text is blank or not visible.
    if (opts.text.len == 0) return;
    if (opts.rs.s == 0) return;
    if (clipped_rect.empty() and opts.ak_opts == null) return;

    const utf8_text = try dvui.toUtf8(cw.lifo(), opts.text);
    defer if (opts.text.ptr != utf8_text.ptr) cw.lifo().free(utf8_text);

    if (!cw.render_target.rendering) {
        var opts_copy = opts;
        opts_copy.text = try cw.arena().dupe(u8, utf8_text);
        if (opts.kern_in) |ki| opts_copy.kern_in = try cw.arena().dupe(u32, ki);
        cw.addRenderCommand(.{ .text = opts_copy }, false);
        return;
    }

    const target_size = opts.font.size * opts.rs.s;
    const sized_font = opts.font.withSize(target_size);

    // might get a slightly smaller font
    var fce = try cw.fonts.getOrCreate(cw.gpa, sized_font);
    var fce_ascent = fce.ascent;
    if (opts.font.line_height_factor < 1.0) {
        fce_ascent = @round(fce_ascent * opts.font.line_height_factor);
    }

    // this must be synced with Font.textSizeEx()
    const target_fraction = if (cw.snap_to_pixels) 1.0 else target_size / fce.em_height;

    // make sure the cache has all the glyphs we need
    if (opts.kern_in == null) {
        // if kern_in is given, assume we already did this when measuring the text
        var utf8it = std.unicode.Utf8View.initUnchecked(utf8_text).iterator();
        while (utf8it.nextCodepoint()) |codepoint| {
            // see halfspace below
            const halfspace = (codepoint == '\n' or codepoint == '\r');
            _ = try fce.glyphInfoGetOrReplacement(cw.gpa, if (halfspace) ' ' else codepoint);
        }
    }

    // Generate new texture atlas if needed to update glyph uv coords
    const texture_atlas = fce.getTextureAtlas(cw.gpa, cw.backend) catch |err| switch (err) {
        error.OutOfMemory => |e| return e,
        else => {
            const fname = opts.font.name(cw.arena());
            defer cw.arena().free(fname);
            dvui.log.err("Could not get texture atlas for font {s}, text area marked in magenta, to display '{s}'", .{ fname, opts.text });
            opts.rs.r.fill(.{}, .{ .color = .magenta });
            return;
        },
    };

    // Over allocate the internal buffers assuming each byte is a character
    var builder = try dvui.Triangles.Builder.init(cw.lifo(), 4 * utf8_text.len, 6 * utf8_text.len);
    defer builder.deinit(cw.lifo());

    const color = opts.color.opacity(cw.alpha);
    const col: Color.PMA = .fromColor(color);

    var start = opts.p orelse opts.rs.r.topLeft();
    if (cw.snap_to_pixels) {
        start.x = @round(start.x);
        start.y = @round(start.y);
    }

    var x = start.x;
    var max_x = start.x;

    if (opts.debug) {
        dvui.log.debug("renderText {f}\n", .{start});
    }

    var sel_in: bool = false;
    var sel_start_x: f32 = x;
    var sel_end_x: f32 = x;
    var sel_max_y: f32 = start.y;
    var sel_start: usize = opts.sel_start orelse 0;
    sel_start = @min(sel_start, utf8_text.len);
    var sel_end: usize = opts.sel_end orelse 0;
    sel_end = @min(sel_end, utf8_text.len);
    // if we will definitely have a selected region or not
    const sel: bool = sel_start < sel_end;

    const atlas_size: Size = .{ .w = @floatFromInt(texture_atlas.width), .h = @floatFromInt(texture_atlas.height) };

    var bytes_seen: usize = 0;
    var last_codepoint: u32 = 0;

    const kerning: bool = opts.kerning orelse cw.kerning;
    var next_kern_idx: u32 = 0;
    var next_kern_byte: u32 = 0;
    if (opts.kern_in) |ki| {
        next_kern_byte = ki[next_kern_idx];
        next_kern_idx += 1;
    }

    var i: usize = 0;
    while (i < opts.text.len) {
        const cplen = std.unicode.utf8ByteSequenceLength(opts.text[i]) catch unreachable;
        const codepoint = std.unicode.utf8Decode(opts.text[i..][0..cplen]) catch unreachable;

        // Render \n, \r as half spaces.  That way if they are part of the
        // selection, you can see it. This matches Chrome's behavior, although
        // this is not a universal - Firefox doesn't do this.  Since this is
        // inside rendering, it doesn't affect text sizing.
        const halfspace = (codepoint == '\n' or codepoint == '\r');
        var gi = try fce.glyphInfoGetOrReplacement(cw.gpa, if (halfspace) ' ' else codepoint);
        if (halfspace) gi.advance = @round(gi.advance * 0.5);

        if (kerning and last_codepoint != 0 and i >= next_kern_byte) {
            const kk = fce.kern(last_codepoint, codepoint);
            x += target_fraction * (if (cw.snap_to_pixels) @round(kk) else kk);

            if (opts.kern_in) |ki| {
                if (next_kern_idx < ki.len) {
                    next_kern_byte = ki[next_kern_idx];
                    next_kern_idx += 1;
                }
            }
        }

        i += cplen;
        last_codepoint = codepoint;

        const adv = if (cw.snap_to_pixels) @round(gi.advance) else gi.advance;
        if (x + gi.leftBearing * target_fraction < start.x) {
            // Glyph extends left of the start, like the first letter being
            // "j", which has a negative left bearing.
            //
            // Shift the whole line over so it starts at x_start.  textSize()
            // includes this extra space.

            //std.debug.print("moving x from {d} to {d}\n", .{ x, x_start - gi.leftBearing * target_fraction });
            start.x -= gi.leftBearing * target_fraction;
            x = start.x;
        }

        const nextx = x + adv * target_fraction;
        const leftx = x + gi.leftBearing * target_fraction;

        if (sel) {
            bytes_seen += std.unicode.utf8CodepointSequenceLength(codepoint) catch unreachable;
            if (!sel_in and bytes_seen > sel_start and bytes_seen <= sel_end) {
                // entering selection
                sel_in = true;
                sel_start_x = @min(x, leftx);
            } else if (sel_in and bytes_seen > sel_end) {
                // leaving selection
                sel_in = false;
            }

            if (sel_in) {
                // update selection
                sel_end_x = nextx;
            }
        }
        if (dvui.accesskit_enabled) {
            if (opts.ak_opts) |_| {
                text_info.append(cw.arena(), .{
                    .l = cplen,
                    .w = if (gi.w == 0) nextx - x else gi.w,
                    .x = std.math.clamp(x - clipped_rect.x, 0, clipped_rect.w),
                }) catch {};
            }
        }

        // don't output triangles for a zero-width glyph (space seems to be the only one)
        if (gi.w > 0) {
            const vtx_offset: dvui.Vertex.Index = @intCast(builder.vertexes.items.len);
            var v: Vertex = undefined;

            v.pos.x = leftx;
            v.pos.y = start.y + (fce_ascent - gi.topBearing) * target_fraction;
            v.col = col;
            v.uv = gi.uv;
            builder.appendVertex(v);

            if (opts.debug) {
                dvui.log.debug(" - x {d} y {d}", .{ v.pos.x, v.pos.y });
            }

            if (opts.debug) {
                //log.debug("{d} pad {d} minx {d} maxx {d} miny {d} maxy {d} x {d} y {d}", .{ bytes_seen, pad, gi.minx, gi.maxx, gi.miny, gi.maxy, v.pos.x, v.pos.y });
                //log.debug("{d} pad {d} left {d} top {d} w {d} h {d} advance {d}", .{ bytes_seen, pad, gi.f2_leftBearing, gi.f2_topBearing, gi.f2_w, gi.f2_h, gi.f2_advance });
            }

            v.pos.x = x + (gi.leftBearing + gi.w) * target_fraction;
            max_x = v.pos.x;
            v.uv[0] = gi.uv[0] + gi.w / atlas_size.w;
            builder.appendVertex(v);

            v.pos.y = start.y + (fce_ascent - gi.topBearing + gi.h) * target_fraction;
            sel_max_y = @max(sel_max_y, v.pos.y);
            v.uv[1] = gi.uv[1] + gi.h / atlas_size.h;
            builder.appendVertex(v);

            v.pos.x = leftx;
            v.uv[0] = gi.uv[0];
            builder.appendVertex(v);

            // triangles must be counter-clockwise (y going down) to avoid backface culling
            builder.appendTriangles(&.{
                vtx_offset + 0, vtx_offset + 2, vtx_offset + 1,
                vtx_offset + 0, vtx_offset + 3, vtx_offset + 2,
            });
        }

        x = nextx;
    }

    if (opts.background_color) |bgcol| {
        opts.rs.r.toPoint(.{
            .x = max_x,
            .y = @max(sel_max_y, opts.rs.r.y + fce.height * target_fraction * opts.font.line_height_factor),
        }).fill(.{}, .{ .color = bgcol, .fade = 0 });
    }

    if (sel) {
        Rect.Physical.fromPoint(.{ .x = sel_start_x, .y = start.y })
            .toPoint(.{
                .x = sel_end_x,
                .y = @max(sel_max_y, start.y + fce.height * target_fraction * opts.font.line_height_factor),
            })
            .fill(.{}, .{ .color = opts.sel_color orelse dvui.themeGet().focus, .fade = 0 });
    }

    if (opts.font.underline) |u| {
        if (u.thick > 0) {
            var topleft: dvui.Point.Physical = .{ .x = start.x, .y = start.y + fce_ascent + (fce.em_height * 0.2) };
            if (cw.snap_to_pixels) {
                // x should already be snapped
                topleft.y = @round(topleft.y);
            }

            const botright: dvui.Point.Physical = .{ .x = max_x, .y = topleft.y + @max(1.0 * opts.rs.s, fce.em_height * u.thick) };

            Rect.Physical.fromPoint(topleft).toPoint(botright).fill(.{}, .{ .color = color, .fade = 0 });
        }
    }

    if (opts.font.strike) |s| {
        if (s.thick > 0) {
            const thick = @max(1.0 * opts.rs.s, fce.em_height * s.thick);
            var topleft: dvui.Point.Physical = .{ .x = start.x, .y = start.y + fce_ascent - (fce.em_height * 0.5) - thick * 0.5 };
            if (cw.snap_to_pixels) {
                // x should already be snapped
                topleft.y = @round(topleft.y);
            }

            const botright: dvui.Point.Physical = .{ .x = max_x, .y = topleft.y + thick };

            Rect.Physical.fromPoint(topleft).toPoint(botright).fill(.{}, .{ .color = color, .fade = 0 });
        }
    }

    var tri = builder.build();
    defer tri.deinit(cw.lifo());

    tri.rotate(.{ .x = start.x, .y = start.y }, opts.rotation);

    try renderTriangles(tri, texture_atlas);

    if (dvui.accesskit_enabled) if (opts.ak_opts) |ak_opts| {
        cw.accesskit.textRunPopulate(opts.text, ak_opts, &text_info, clipped_rect);
    };
}