Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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());}