// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! A line of text being typed: the find bar's. //! //! The text is UTF-8, and the caret moves a code point at a time. Text //! longer than the field scrolls, so that the caret is always in view. //! After the field is focused with its text selected, typing replaces it, //! as a search field's does. const std = @import("std"); const Allocator = std.mem.Allocator; const aerie = @import("aerie"); const Canvas = aerie.gpu.Canvas; const Rect = Canvas.Rect; const Shaper = aerie.text.Shaper; const Text = @import("Text.zig"); const theme = @import("theme.zig"); const TextField = @This(); buf: std.ArrayList(u8) = .empty, /// A byte offset, always on a code point's start. cursor: usize = 0, /// Everything is selected: what is typed next replaces it. all_selected: bool = false, /// How far the text is scrolled left, in pixels. scroll_x: f32 = 0, pub fn deinit(f: *TextField, gpa: Allocator) void { f.buf.deinit(gpa); f.* = undefined; } pub fn text(f: *const TextField) []const u8 { return f.buf.items; } pub fn selectAll(f: *TextField) void { f.all_selected = f.buf.items.len > 0; f.cursor = f.buf.items.len; } /// Puts `s` at the caret, in place of everything if it is all selected. pub fn insert(f: *TextField, gpa: Allocator, s: []const u8) Allocator.Error!void { if (f.all_selected) f.clear(); // Only what is UTF-8, and nothing that is not text — a line break, a // tab, what Return or Backspace type — since this is one line. var it = (std.unicode.Utf8View.init(s) catch return).iterator(); while (it.nextCodepointSlice()) |cp| { if (cp.len == 1 and (cp[0] < 0x20 or cp[0] == 0x7f)) continue; try f.buf.insertSlice(gpa, f.cursor, cp); f.cursor += cp.len; } } pub fn clear(f: *TextField) void { f.buf.clearRetainingCapacity(); f.cursor = 0; f.all_selected = false; } /// The start of the code point before `i`. fn before(s: []const u8, i: usize) usize { if (i == 0) return 0; var j = i - 1; while (j > 0 and s[j] & 0xc0 == 0x80) j -= 1; return j; } /// The start of the code point after the one at `i`. fn after(s: []const u8, i: usize) usize { if (i >= s.len) return s.len; var j = i + 1; while (j < s.len and s[j] & 0xc0 == 0x80) j += 1; return j; } pub fn backspace(f: *TextField) void { if (f.all_selected) return f.clear(); const start = before(f.buf.items, f.cursor); f.buf.replaceRangeAssumeCapacity(start, f.cursor - start, &.{}); f.cursor = start; } pub fn delete(f: *TextField) void { if (f.all_selected) return f.clear(); const next = after(f.buf.items, f.cursor); f.buf.replaceRangeAssumeCapacity(f.cursor, next - f.cursor, &.{}); } pub fn left(f: *TextField) void { f.all_selected = false; f.cursor = before(f.buf.items, f.cursor); } pub fn right(f: *TextField) void { f.all_selected = false; f.cursor = after(f.buf.items, f.cursor); } pub fn home(f: *TextField) void { f.all_selected = false; f.cursor = 0; } pub fn end(f: *TextField) void { f.all_selected = false; f.cursor = f.buf.items.len; } /// Puts the caret nearest `x`, in pixels from the field's text origin. pub fn place(f: *TextField, t: *Text, x: f32) !void { f.all_selected = false; const fam = t.family(.ui); t.shaped.clearRetainingCapacity(); const w = try t.shaper.shape(fam, fam.size_px, .regular, f.buf.items, &t.shaped); f.cursor = Shaper.offsetAt(t.shaped.items, w, f.buf.items.len, x + f.scroll_x); } /// Draws the field in `r`, with the caret when it is `focused`. pub fn draw(f: *TextField, canvas: *Canvas, t: *Text, r: Rect, focused: bool, scale: f32) !void { try canvas.rect(r.x, r.y, r.width, r.height, theme.current.list_background); try canvas.frame(r.x, r.y, r.width, r.height, @max(1, @round(scale)), if (focused) theme.current.selection_background else theme.current.divider); const pad = @round(5 * scale); const inner: Rect = .{ .x = r.x + pad, .y = r.y, .width = @max(0, r.width - 2 * pad), .height = r.height }; const saved = canvas.pushClip(inner); defer canvas.setClip(saved); const fam = t.family(.ui); t.shaped.clearRetainingCapacity(); const w = try t.shaper.shape(fam, fam.size_px, .regular, f.buf.items, &t.shaped); const caret = Shaper.xOf(t.shaped.items, w, f.cursor); // Scrolled as little as keeps the caret in view. if (caret - f.scroll_x > inner.width - 1) f.scroll_x = caret - inner.width + 1; if (caret < f.scroll_x) f.scroll_x = caret; if (w <= inner.width) f.scroll_x = 0; const x0 = inner.x - f.scroll_x; if (focused and f.all_selected) try canvas.rect(x0, r.y + @round(3 * scale), w, r.height - 2 * @round(3 * scale), theme.current.text_selection); try canvas.text(&t.glyphs, t.shaped.items, fam.size_px, x0, r.y + t.baselineIn(.ui, r.height), theme.current.foreground); if (focused) try canvas.rect(@round(x0 + caret), r.y + @round(4 * scale), @max(1, @round(scale)), r.height - 2 * @round(4 * scale), theme.current.foreground); } const testing = std.testing; test "typing, moving and deleting by code point" { var f: TextField = .{}; defer f.deinit(testing.allocator); try f.insert(testing.allocator, "café"); try testing.expectEqual(@as(usize, 5), f.cursor); f.backspace(); try testing.expectEqualStrings("caf", f.text()); f.left(); f.left(); try f.insert(testing.allocator, "é"); try testing.expectEqualStrings("céaf", f.text()); f.home(); f.delete(); try testing.expectEqualStrings("éaf", f.text()); f.right(); try testing.expectEqual(@as(usize, 2), f.cursor); f.end(); try testing.expectEqual(f.text().len, f.cursor); } test "selected text is replaced by what is typed next" { var f: TextField = .{}; defer f.deinit(testing.allocator); try f.insert(testing.allocator, "old"); f.selectAll(); try f.insert(testing.allocator, "n"); try testing.expectEqualStrings("n", f.text()); f.selectAll(); f.backspace(); try testing.expectEqualStrings("", f.text()); } test "a line break typed is no part of the line" { var f: TextField = .{}; defer f.deinit(testing.allocator); try f.insert(testing.allocator, "a\nb\r"); try testing.expectEqualStrings("ab", f.text()); }