From ce9d1e66840608b8b2012370c5a554a15257d2df Mon Sep 17 00:00:00 2001 From: Alessandro Mauri Date: Thu, 16 Oct 2025 17:36:23 +0200 Subject: [PATCH] string layout with custom iterator --- lib/ugui.c3l/src/font.c3 | 3 + lib/ugui.c3l/src/string.c3 | 594 +++++++++++++++++-------------------- 2 files changed, 278 insertions(+), 319 deletions(-) diff --git a/lib/ugui.c3l/src/font.c3 b/lib/ugui.c3l/src/font.c3 index 3362a6a..90be1c1 100644 --- a/lib/ugui.c3l/src/font.c3 +++ b/lib/ugui.c3l/src/font.c3 @@ -65,6 +65,9 @@ struct Font { 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 [in] name diff --git a/lib/ugui.c3l/src/string.c3 b/lib/ugui.c3l/src/string.c3 index b0ca2f7..ef74dcf 100644 --- a/lib/ugui.c3l/src/string.c3 +++ b/lib/ugui.c3l/src/string.c3 @@ -9,6 +9,8 @@ struct LineInfo @local { short first_off; // first character offset } +macro usz LineInfo.len(li) => li.end-li.start; + alias LineStack @local = list::List{LineInfo}; 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[in] text +@param [&inout] self +@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) { - return ctx.layout_string_topleft(text, bounds, z_index, hue, reflow); - } else { - return ctx.layout_string_aligned(text, bounds, anchor, z_index, hue, reflow); + self.font = font; + + self.line_height = (short)font.line_height(); + 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 -// [ ] 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) + +fn void? GlyphIterator.populate_lines_stack(&self) { - 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; LineInfo li; - Point o; - StringIterator ti = text.iterator(); + Point o = self.o; + StringIterator ti = self.text.iterator(); + + usz prev_off; 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; + li.height = self.line_height; switch { case cp == '\n': push = true; case cp == '\t': - o.x += tab_width; + o.x += self.tab_width; case ascii::is_cntrl((char)cp): break; default: - if (off == line_start) li.first_off = gp.ox; + Glyph* gp = self.font.get_glyph(cp)!; - Rect b = { - .x = o.x + gp.ox, - .y = o.y + gp.oy + baseline, - .w = gp.w, - .h = gp.h, - }; + if (off == line_start) { + li.first_off = gp.ox; + o.x -= gp.ox; + } - if (reflow && b.x + b.w > bounds.w) { - li.width += gp.ox + gp.w; - li.height = line_height; + Rect b = gp.bounds().off(o); + b.y += self.baseline; + + if (self.reflow && b.x + b.w > self.bounds.x + self.bounds.w) { push = true; + // roll back this character since it is on the next line + ti.current = prev_off; + off = prev_off; } else { o.x += gp.adv; li.width += gp.adv; - li.height = line_height; } } if (push) { li.start = line_start; li.end = off; - lines.push(li); - str_bounds.w = max(str_bounds.w, li.width); - str_bounds.h += li.height; + self.lines.push(li); + self.str_bounds.w = max(self.str_bounds.w, li.width); + self.str_bounds.h += li.height; - o.x = 0; - o.y += line_height; + o.x = self.bounds.x; + o.y += self.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 - 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; + prev_off = off; } - - ctx.reset_scissor(z_index)!; -// ctx.dbg_rect(str_bounds.off(bounds.position())); + if (line_start != ti.current) { + // FIXME: crap, can we not repeat this code? + li.start = line_start; + li.end = ti.current; + self.lines.push(li); + self.str_bounds.w = max(self.str_bounds.w, li.width); + self.str_bounds.h += li.height; + } + + self.str_bounds.h += (short)(self.lines.len()-1) * self.line_gap; } -// 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] text +@param [&in] ctx +@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 (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; 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(); - StringIterator it = text.iterator(); - for (Codepoint cp; it.has_next();) { - cp = it.next()!; - Glyph* gp = font.get_glyph(cp)!; - - 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; - } + + GlyphIterator gi; + gi.init(tmem, text, bounds, font, anchor, reflow, TAB_SIZE)!; + while (gi.has_next()) { + Rect b = gi.next()!; + Rect uv = gi.gp.uv(); + ctx.push_sprite(b, uv, texture_id, z_index, hue)!; } ctx.reset_scissor(z_index)!; +// ctx.dbg_rect(str_bounds.off(bounds.position())); } - // ---------------------------------------------------------------------------------- // // 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) { - if (anchor != TOP_LEFT) { - unreachable("TODO: anchor has to be TOP_LEFT"); - } - if (bounds.w <= 0 || bounds.h <= 0) return {}; - if (text == "") text = "\f"; 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; + if (text == "") text = "\f"; + + GlyphIterator gi; + gi.init(tmem, text, bounds, font, anchor, reflow, TAB_SIZE)!; Rect cursor_rect; - cursor_rect.x = bounds.x; - cursor_rect.y = bounds.y; - cursor_rect.h = line_height; - + cursor_rect.x = gi.o.x; + cursor_rect.y = gi.o.y; + cursor_rect.h = (short)font.line_height(); if (cursor == 0) return cursor_rect; - Point o = bounds.position(); - StringIterator it = text.iterator(); - usz off; - for (Codepoint cp; it.has_next();) { - cp = it.next()!; - off = it.current; - Glyph* gp = font.get_glyph(cp)!; - - 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, - }; - - 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; + while (gi.has_next()) { + Rect b = gi.next()!; + if (gi.current_offset() == cursor) { + if (gi.cp == '\n') { + if (!gi.has_next()) { + cursor_rect.x = bounds.x + bounds.x_off(0, anchor); + cursor_rect.y = b.y + gi.line_height + gi.line_gap; + } else { + gi.next()!; + cursor_rect.x = gi.o.x - gi.gp.adv; + cursor_rect.y = gi.o.y; + } + } else { + // Use the updated origin position instead of glyph bounds + cursor_rect.x = gi.o.x; + cursor_rect.y = gi.o.y; } - - o.x += gp.adv; - } - if (off == cursor) { - cursor_rect.x = o.x; - cursor_rect.y = o.y; return cursor_rect; } } + 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; 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; - LineInfo li; - 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; - + + GlyphIterator gi; + gi.init(tmem, text, bounds, font, anchor, reflow, TAB_SIZE)!; + + usz prev_offset = 0; + Point prev_o = gi.o; + + while (gi.has_next()) { + Point o_before = gi.o; + usz offset_before = gi.current_offset(); + Rect b = gi.next()!; + switch { - case cp == '\n': - push = true; - case cp == '\t': - o.x += tab_width; - case ascii::is_cntrl((char)cp): + case gi.cp == '\n': + // Check if point is on this line before the newline + Rect line_rect = { + .x = prev_o.x, + .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; default: - if (off == line_start) li.first_off = gp.ox; - - Rect b = { - .x = o.x + gp.ox, - .y = o.y + gp.oy + baseline, - .w = gp.w, - .h = gp.h, + // Create a hit test rect for this character + Rect hit_rect = { + .x = o_before.x, + .y = o_before.y, + .w = gi.gp.adv, + .h = gi.line_height }; - if (reflow && b.x + b.w > bounds.w) { - li.width += gp.ox + gp.w; - li.height = line_height; - push = true; - } else { - o.x += gp.adv; - li.width += gp.adv; - li.height = line_height; + if (p.in_rect(hit_rect)) { + // Check if cursor should be before or after this character + // by checking which half of the character was clicked + short mid_x = o_before.x + gi.gp.adv / 2; + if (p.x < mid_x) { + return offset_before; + } else { + return gi.current_offset(); + } } } - if (push) { - 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; - } + prev_offset = gi.current_offset(); } - // 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 - 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; - } + // Point is after all text return text.len; } @@ -533,12 +493,8 @@ fn TextSize? Ctx.measure_string(&ctx, String text) case ascii::is_cntrl((char)cp): break; default: - Rect b = { - .x = origin.x + gp.ox, - .y = origin.y + gp.oy + baseline, - .w = gp.w, - .h = gp.h, - }; + Rect b = gp.bounds().off(origin); + b.y += baseline; bounds = containing_rect(bounds, b); origin.x += gp.adv; }