string layout with custom iterator

This commit is contained in:
Alessandro Mauri 2025-10-16 17:36:23 +02:00
parent 05a6d4803e
commit ce9d1e6684
2 changed files with 278 additions and 319 deletions

View File

@ -65,6 +65,9 @@ struct Font {
bool should_update; // should send update_atlas command, resets at frame_end() bool should_update; // should send update_atlas command, resets at frame_end()
} }
macro Rect Glyph.bounds(&g) => {.x = g.ox, .y = g.oy, .w = g.w, .h = g.h};
macro Rect Glyph.uv(&g) => {.x = g.u, .y = g.v, .w = g.w, .h = g.h};
<* <*
@param [&inout] font @param [&inout] font
@param [in] name @param [in] name

View File

@ -9,6 +9,8 @@ struct LineInfo @local {
short first_off; // first character offset short first_off; // first character offset
} }
macro usz LineInfo.len(li) => li.end-li.start;
alias LineStack @local = list::List{LineInfo}; alias LineStack @local = list::List{LineInfo};
fn short Rect.y_off(Rect bounds, short height, Anchor anchor) @local fn short Rect.y_off(Rect bounds, short height, Anchor anchor) @local
@ -60,162 +62,239 @@ fn short Rect.x_off(Rect bounds, short width, Anchor anchor) @local
// ---------------------------------------------------------------------------------- // // ---------------------------------------------------------------------------------- //
struct GlyphIterator {
short baseline;
short line_height;
short line_gap;
short space_width;
short tab_width;
Rect bounds;
Anchor anchor;
bool reflow;
Font* font;
LineStack lines;
usz line_off, line_idx;
String text;
Codepoint cp;
Glyph* gp;
Point o;
Rect str_bounds;
}
<* <*
@param[&in] ctx @param [&inout] self
@param[in] text @param [in] text
@param [&inout] font
*> *>
fn void? Ctx.layout_string(&ctx, String text, Rect bounds, Anchor anchor, int z_index, Color hue, bool reflow = false) fn void? GlyphIterator.init(&self, Allocator allocator, String text, Rect bounds, Font* font, Anchor anchor, bool reflow, uint tab_size)
{ {
if (anchor == TOP_LEFT) { self.font = font;
return ctx.layout_string_topleft(text, bounds, z_index, hue, reflow);
} else { self.line_height = (short)font.line_height();
return ctx.layout_string_aligned(text, bounds, anchor, z_index, hue, reflow); self.baseline = (short)font.ascender;
self.line_gap = (short)font.linegap;
self.space_width = font.get_glyph(' ').adv!;
self.tab_width = self.space_width * (short)tab_size;
self.bounds = bounds;
self.o = bounds.position();
self.reflow = reflow;
self.text = text;
self.anchor = anchor;
// if the anchor is top_left we can skip dividing the string line by line, in GlyphIterator.next
// this has to be accounted for
if (anchor != TOP_LEFT) {
self.lines.init(allocator, 4);
self.populate_lines_stack()!;
self.line_off = 0;
self.line_idx = 0;
if (self.lines.len() > 0) {
self.o.y += bounds.y_off(self.str_bounds.h, anchor);
self.o.x += bounds.x_off(self.lines[0].width, anchor) - self.lines[0].first_off;
}
} }
} }
// layout a string inside a bounding box, following the given alignment (anchor).
// TODO: Following improvements fn void? GlyphIterator.populate_lines_stack(&self)
// [ ] implement a macro to fetch and layout each character, this can be used to reduce code
// repetition both here and in measure_string
// [ ] implement a function hit_test_string() to get the character position at point, this can
// be used to implement mouse interactions, like cursor movement and selection
<*
@param [&in] ctx
@param [in] text
*>
fn void? Ctx.layout_string_aligned(&ctx, String text, Rect bounds, Anchor anchor, int z_index, Color hue, bool reflow = false)
{ {
if (text == "") return;
if (bounds.w <= 0 || bounds.h <= 0) return;
ctx.push_scissor(bounds, z_index)!;
Font* font = &ctx.font;
Id texture_id = font.id;
short line_height = (short)font.line_height();
short baseline = (short)font.ascender;
short line_gap = (short)font.linegap;
short space_width = font.get_glyph(' ').adv!;
short tab_width = space_width * TAB_SIZE;
Point origin = bounds.position();
LineStack lines;
lines.init(tmem, 1);
Rect str_bounds;
usz line_start; usz line_start;
LineInfo li; LineInfo li;
Point o; Point o = self.o;
StringIterator ti = text.iterator(); StringIterator ti = self.text.iterator();
usz prev_off;
for (Codepoint cp; ti.has_next();) { for (Codepoint cp; ti.has_next();) {
// FIXME: what if the interface changes?
cp = ti.next()!; cp = ti.next()!;
usz off = ti.current; usz off = ti.current;
Glyph* gp = font.get_glyph(cp)!;
bool push = false; bool push = false;
li.height = self.line_height;
switch { switch {
case cp == '\n': case cp == '\n':
push = true; push = true;
case cp == '\t': case cp == '\t':
o.x += tab_width; o.x += self.tab_width;
case ascii::is_cntrl((char)cp): case ascii::is_cntrl((char)cp):
break; break;
default: default:
if (off == line_start) li.first_off = gp.ox; Glyph* gp = self.font.get_glyph(cp)!;
Rect b = { if (off == line_start) {
.x = o.x + gp.ox, li.first_off = gp.ox;
.y = o.y + gp.oy + baseline, o.x -= gp.ox;
.w = gp.w, }
.h = gp.h,
};
if (reflow && b.x + b.w > bounds.w) { Rect b = gp.bounds().off(o);
li.width += gp.ox + gp.w; b.y += self.baseline;
li.height = line_height;
if (self.reflow && b.x + b.w > self.bounds.x + self.bounds.w) {
push = true; push = true;
// roll back this character since it is on the next line
ti.current = prev_off;
off = prev_off;
} else { } else {
o.x += gp.adv; o.x += gp.adv;
li.width += gp.adv; li.width += gp.adv;
li.height = line_height;
} }
} }
if (push) { if (push) {
li.start = line_start; li.start = line_start;
li.end = off; li.end = off;
lines.push(li); self.lines.push(li);
str_bounds.w = max(str_bounds.w, li.width); self.str_bounds.w = max(self.str_bounds.w, li.width);
str_bounds.h += li.height; self.str_bounds.h += li.height;
o.x = 0; o.x = self.bounds.x;
o.y += line_height; o.y += self.line_height;
line_start = off; line_start = off;
li.height = 0; li.height = 0;
li.width = 0; li.width = 0;
} }
prev_off = off;
} }
// FIXME: crap if (line_start != ti.current) {
li.start = line_start; // FIXME: crap, can we not repeat this code?
li.end = ti.current; li.start = line_start;
lines.push(li); li.end = ti.current;
str_bounds.w = max(str_bounds.w, li.width); self.lines.push(li);
str_bounds.h += li.height; self.str_bounds.w = max(self.str_bounds.w, li.width);
self.str_bounds.h += li.height;
// account for the line gap
str_bounds.h += (short)(lines.len() - 1)*line_gap;
o = bounds.position();
o.y += bounds.y_off(str_bounds.h, anchor);
foreach (idx, line : lines) {
o.x = bounds.x + bounds.x_off(line.width, anchor) - line.first_off;
StringIterator s = text[line.start:line.end-line.start].iterator();
for (Codepoint cp; s.has_next();) {
cp = s.next()!;
Glyph* gp = font.get_glyph(cp)!;
switch {
case cp == '\n':
break;
case cp == '\t':
o.x += tab_width;
case ascii::is_cntrl((char)cp):
break;
default:
Rect b = {
.x = o.x + gp.ox,
.y = o.y + gp.oy + baseline,
.w = gp.w,
.h = gp.h,
};
Rect uv = {
.x = gp.u,
.y = gp.v,
.w = gp.w,
.h = gp.h
};
ctx.push_sprite(b, uv, texture_id, z_index, hue)!;
o.x += gp.adv;
}
}
o.y += line.height + line_gap;
} }
ctx.reset_scissor(z_index)!; self.str_bounds.h += (short)(self.lines.len()-1) * self.line_gap;
// ctx.dbg_rect(str_bounds.off(bounds.position()));
} }
// layout a string inside a bounding box, with TOP_LEFT alignment. fn String GlyphIterator.current_line(&self)
{
LineInfo li = self.lines[self.line_idx];
return self.text[li.start:li.len()];
}
fn Rect? GlyphIterator.next(&self)
{
// check if there is a next glyph and maybe update the line and offset indices
if (self.anchor != TOP_LEFT) {
if (self.line_idx >= self.lines.len()) {
return NO_MORE_ELEMENT?;
}
LineInfo li = self.lines[self.line_idx];
if (self.line_off >= li.len()) {
self.line_idx++;
if (self.line_idx >= self.lines.len()) {
return NO_MORE_ELEMENT?;
}
self.line_off = 0;
li = self.lines[self.line_idx];
self.o.y += self.line_height + self.line_gap;
self.o.x = self.bounds.x + self.bounds.x_off(li.width, self.anchor) - li.first_off;
}
} else if (self.line_off >= self.text.len) {
return NO_MORE_ELEMENT?;
}
String t;
if (self.anchor != TOP_LEFT) {
t = self.current_line()[self.line_off..];
} else {
t = self.text[self.line_off..];
}
usz read = t.len < 4 ? t.len : 4;
self.cp = conv::utf8_to_char32(&t[0], &read)!;
self.line_off += read;
self.gp = self.font.get_glyph(self.cp)!;
Rect b = {.x = self.o.x, .y = self.o.y};
switch {
case self.cp == '\n':
if (self.anchor == TOP_LEFT) {
self.o.x = self.bounds.x;
self.o.y += self.line_height + self.line_gap;
}
break;
case self.cp == '\t':
self.o.x += self.tab_width;
case ascii::is_cntrl((char)self.cp):
break;
default:
b = self.gp.bounds().off(self.o);
b.y += self.baseline;
if (self.anchor == TOP_LEFT) {
if (self.o.x == self.bounds.x) self.bounds.x -= self.gp.ox;
if (self.reflow && b.bottom_right().x > self.bounds.bottom_right().x) {
self.o.x = self.bounds.x - self.gp.ox;
self.o.y += self.line_height + self.line_gap;
b = self.gp.bounds().off(self.o);
b.y += self.baseline;
}
}
self.o.x += self.gp.adv;
}
return b;
}
fn bool GlyphIterator.has_next(&self)
{
if (self.anchor == TOP_LEFT) {
return self.line_off < self.text.len;
}
if (self.line_idx >= self.lines.len()) {
return false;
}
LineInfo li = self.lines[self.line_idx];
if (self.line_idx == self.lines.len() - 1 && self.line_off >= li.len()) {
return false;
}
return true;
}
fn usz GlyphIterator.current_offset(&self) => self.lines[self.line_idx].start + self.line_off;
// layout a string inside a bounding box, following the given alignment (anchor).
<* <*
@param[&in] ctx @param [&in] ctx
@param[in] text @param [in] text
*> *>
fn void? Ctx.layout_string_topleft(&ctx, String text, Rect bounds, int z_index, Color hue, bool reflow = false) fn void? Ctx.layout_string(&ctx, String text, Rect bounds, Anchor anchor, int z_index, Color hue, bool reflow = false)
{ {
if (text == "") return; if (text == "") return;
if (bounds.w <= 0 || bounds.h <= 0) return; if (bounds.w <= 0 || bounds.h <= 0) return;
@ -223,129 +302,62 @@ fn void? Ctx.layout_string_topleft(&ctx, String text, Rect bounds, int z_index,
Font* font = &ctx.font; Font* font = &ctx.font;
Id texture_id = font.id; Id texture_id = font.id;
short line_height = (short)font.line_height();
short baseline = (short)font.ascender;
short line_gap = (short)font.linegap;
short space_width = font.get_glyph(' ').adv!;
short tab_width = space_width * TAB_SIZE;
Point o = bounds.position(); GlyphIterator gi;
StringIterator it = text.iterator(); gi.init(tmem, text, bounds, font, anchor, reflow, TAB_SIZE)!;
for (Codepoint cp; it.has_next();) { while (gi.has_next()) {
cp = it.next()!; Rect b = gi.next()!;
Glyph* gp = font.get_glyph(cp)!; Rect uv = gi.gp.uv();
ctx.push_sprite(b, uv, texture_id, z_index, hue)!;
switch {
case cp == '\n':
o.y += line_height;
o.x = bounds.x;
break;
case cp == '\t':
o.x += tab_width;
case ascii::is_cntrl((char)cp):
break;
default:
Rect b = {
.x = o.x + gp.ox,
.y = o.y + gp.oy + baseline,
.w = gp.w,
.h = gp.h,
};
Rect uv = {
.x = gp.u,
.y = gp.v,
.w = gp.w,
.h = gp.h
};
if (reflow && b.x + b.w > bounds.x + bounds.w) {
o.y += line_height + line_gap;
o.x = bounds.x;
b.x = o.x + gp.ox;
b.y = o.y + gp.oy + baseline;
}
ctx.push_sprite(b, uv, texture_id, z_index, hue)!;
o.x += gp.adv;
}
} }
ctx.reset_scissor(z_index)!; ctx.reset_scissor(z_index)!;
// ctx.dbg_rect(str_bounds.off(bounds.position()));
} }
// ---------------------------------------------------------------------------------- // // ---------------------------------------------------------------------------------- //
// CURSOR AND MOUSE // // CURSOR AND MOUSE //
// ---------------------------------------------------------------------------------- // // ---------------------------------------------------------------------------------- //
// TODO: get_cursor_position and hit_test_string can be implemented with a glyph_iterator that
// returns the position and offset in the string of each glyph
fn Rect? Ctx.get_cursor_position(&ctx, String text, Rect bounds, Anchor anchor, usz cursor, bool reflow = false) fn Rect? Ctx.get_cursor_position(&ctx, String text, Rect bounds, Anchor anchor, usz cursor, bool reflow = false)
{ {
if (anchor != TOP_LEFT) {
unreachable("TODO: anchor has to be TOP_LEFT");
}
if (bounds.w <= 0 || bounds.h <= 0) return {}; if (bounds.w <= 0 || bounds.h <= 0) return {};
if (text == "") text = "\f";
Font* font = &ctx.font; Font* font = &ctx.font;
Id texture_id = font.id; Id texture_id = font.id;
short line_height = (short)font.line_height();
short baseline = (short)font.ascender;
short line_gap = (short)font.linegap;
short space_width = font.get_glyph(' ').adv!;
short tab_width = space_width * TAB_SIZE;
if (text == "") text = "\f";
GlyphIterator gi;
gi.init(tmem, text, bounds, font, anchor, reflow, TAB_SIZE)!;
Rect cursor_rect; Rect cursor_rect;
cursor_rect.x = bounds.x; cursor_rect.x = gi.o.x;
cursor_rect.y = bounds.y; cursor_rect.y = gi.o.y;
cursor_rect.h = line_height; cursor_rect.h = (short)font.line_height();
if (cursor == 0) return cursor_rect; if (cursor == 0) return cursor_rect;
Point o = bounds.position(); while (gi.has_next()) {
StringIterator it = text.iterator(); Rect b = gi.next()!;
usz off; if (gi.current_offset() == cursor) {
for (Codepoint cp; it.has_next();) { if (gi.cp == '\n') {
cp = it.next()!; if (!gi.has_next()) {
off = it.current; cursor_rect.x = bounds.x + bounds.x_off(0, anchor);
Glyph* gp = font.get_glyph(cp)!; cursor_rect.y = b.y + gi.line_height + gi.line_gap;
} else {
switch { gi.next()!;
case cp == '\n': cursor_rect.x = gi.o.x - gi.gp.adv;
o.y += line_height; cursor_rect.y = gi.o.y;
o.x = bounds.x; }
break; } else {
case cp == '\t': // Use the updated origin position instead of glyph bounds
o.x += tab_width; cursor_rect.x = gi.o.x;
case ascii::is_cntrl((char)cp): cursor_rect.y = gi.o.y;
break;
default:
Rect b = {
.x = o.x + gp.ox,
.y = o.y + gp.oy + baseline,
.w = gp.w,
.h = gp.h,
};
if (reflow && b.x + b.w > bounds.x + bounds.w) {
o.y += line_height + line_gap;
o.x = bounds.x;
b.x = o.x + gp.ox;
b.y = o.y + gp.oy + baseline;
} }
o.x += gp.adv;
}
if (off == cursor) {
cursor_rect.x = o.x;
cursor_rect.y = o.y;
return cursor_rect; return cursor_rect;
} }
} }
return {}; return {};
} }
@ -359,119 +371,67 @@ fn usz? Ctx.hit_test_string(&ctx, String text, Rect bounds, Anchor anchor, Point
if (bounds.w <= 0 || bounds.h <= 0) return 0; if (bounds.w <= 0 || bounds.h <= 0) return 0;
Font* font = &ctx.font; Font* font = &ctx.font;
Id texture_id = font.id;
short line_height = (short)font.line_height();
short baseline = (short)font.ascender;
short line_gap = (short)font.linegap;
short space_width = font.get_glyph(' ').adv!;
short tab_width = space_width * TAB_SIZE;
Point origin = bounds.position(); GlyphIterator gi;
gi.init(tmem, text, bounds, font, anchor, reflow, TAB_SIZE)!;
LineStack lines; usz prev_offset = 0;
lines.init(tmem, 1); Point prev_o = gi.o;
Rect str_bounds; while (gi.has_next()) {
Point o_before = gi.o;
usz line_start; usz offset_before = gi.current_offset();
LineInfo li; Rect b = gi.next()!;
Point o;
StringIterator ti = text.iterator();
for (Codepoint cp; ti.has_next();) {
// FIXME: what if the interface changes?
cp = ti.next()!;
usz off = ti.current;
Glyph* gp = font.get_glyph(cp)!;
bool push = false;
switch { switch {
case cp == '\n': case gi.cp == '\n':
push = true; // Check if point is on this line before the newline
case cp == '\t': Rect line_rect = {
o.x += tab_width; .x = prev_o.x,
case ascii::is_cntrl((char)cp): .y = prev_o.y,
.w = (short)(o_before.x - prev_o.x),
.h = gi.line_height
};
if (p.in_rect(line_rect)) return offset_before;
prev_o = gi.o;
break;
case gi.cp == '\t':
// Check if point is in the tab space
Rect tab_rect = {
.x = o_before.x,
.y = o_before.y,
.w = gi.tab_width,
.h = gi.line_height
};
if (p.in_rect(tab_rect)) return offset_before;
break;
case ascii::is_cntrl((char)gi.cp):
break; break;
default: default:
if (off == line_start) li.first_off = gp.ox; // Create a hit test rect for this character
Rect hit_rect = {
Rect b = { .x = o_before.x,
.x = o.x + gp.ox, .y = o_before.y,
.y = o.y + gp.oy + baseline, .w = gi.gp.adv,
.w = gp.w, .h = gi.line_height
.h = gp.h,
}; };
if (reflow && b.x + b.w > bounds.w) { if (p.in_rect(hit_rect)) {
li.width += gp.ox + gp.w; // Check if cursor should be before or after this character
li.height = line_height; // by checking which half of the character was clicked
push = true; short mid_x = o_before.x + gi.gp.adv / 2;
} else { if (p.x < mid_x) {
o.x += gp.adv; return offset_before;
li.width += gp.adv; } else {
li.height = line_height; return gi.current_offset();
}
} }
} }
if (push) { prev_offset = gi.current_offset();
li.start = line_start;
li.end = off;
lines.push(li);
str_bounds.w = max(str_bounds.w, li.width);
str_bounds.h += li.height;
o.x = 0;
o.y += line_height;
line_start = off;
li.height = 0;
li.width = 0;
}
} }
// FIXME: crap
li.start = line_start;
li.end = ti.current;
lines.push(li);
str_bounds.w = max(str_bounds.w, li.width);
str_bounds.h += li.height;
// account for the line gap // Point is after all text
str_bounds.h += (short)(lines.len() - 1)*line_gap;
o = bounds.position();
o.y += bounds.y_off(str_bounds.h, anchor);
foreach (idx, line : lines) {
o.x = bounds.x + bounds.x_off(line.width, anchor) - line.first_off;
StringIterator s = text[line.start:line.end-line.start].iterator();
usz prev;
for (Codepoint cp; s.has_next(); prev = s.current) {
cp = s.next()!;
Glyph* gp = font.get_glyph(cp)!;
switch {
case cp == '\n':
break;
case cp == '\t':
o.x += tab_width;
case ascii::is_cntrl((char)cp):
break;
default:
Rect b = {
.x = o.x + gp.ox,
.y = o.y + gp.oy + baseline,
.w = gp.w,
.h = gp.h,
};
// TODO: skip lines if p is not inside them
Rect r = { .x = b.x, .y = b.y - gp.ox, .w = b.w, .h = line.height};
if (p.in_rect(r)) return line.start + prev;
o.x += gp.adv;
}
}
o.y += line.height + line_gap;
}
return text.len; return text.len;
} }
@ -533,12 +493,8 @@ fn TextSize? Ctx.measure_string(&ctx, String text)
case ascii::is_cntrl((char)cp): case ascii::is_cntrl((char)cp):
break; break;
default: default:
Rect b = { Rect b = gp.bounds().off(origin);
.x = origin.x + gp.ox, b.y += baseline;
.y = origin.y + gp.oy + baseline,
.w = gp.w,
.h = gp.h,
};
bounds = containing_rect(bounds, b); bounds = containing_rect(bounds, b);
origin.x += gp.adv; origin.x += gp.adv;
} }