diff --git a/libs/ui/include/psemek/ui/label.hpp b/libs/ui/include/psemek/ui/label.hpp index f1adff0e..2415b7ca 100644 --- a/libs/ui/include/psemek/ui/label.hpp +++ b/libs/ui/include/psemek/ui/label.hpp @@ -5,6 +5,7 @@ #include #include +#include namespace psemek::ui { @@ -74,7 +75,6 @@ namespace psemek::ui virtual void on_state_changed(); private: - std::string text_; halignment halign_ = halignment::left; valignment valign_ = valignment::top; bool wrap_ = true; @@ -83,6 +83,17 @@ namespace psemek::ui box_shape shape_; + struct text_chunk + { + std::string_view text; + text_style style; // to be OR'd with base style + }; + + using chunk = std::variant; + + std::string text_; + std::vector chunks_; + struct cached_state { struct image diff --git a/libs/ui/source/label.cpp b/libs/ui/source/label.cpp index c0e001fe..538dd2ee 100644 --- a/libs/ui/source/label.cpp +++ b/libs/ui/source/label.cpp @@ -7,12 +7,17 @@ namespace psemek::ui { label::label(std::string text) - : text_(std::move(text)) - {} + { + set_text(std::move(text)); + } void label::set_text(std::string text) { text_ = std::move(text); + + chunks_.clear(); + chunks_.push_back(text_chunk{{text_.data(), text_.size()}, text_style{}}); + on_state_changed(); } @@ -134,232 +139,323 @@ namespace psemek::ui { auto state = cached_state{}; - if (text_.empty()) return state; + if (chunks_.empty()) return state; auto st = merged_own_style(); - if (!st) return state; - auto font = st->text_style->is_set(text_style_flag::bold) ? st->bold_font.get() : st->font.get(); + auto const font_height = st->font->size()[1] * (*st->text_scale); - bool const underline = st->text_style->is_set(text_style_flag::underline); - bool const strikethrough = st->text_style->is_set(text_style_flag::strikethrough); + // Shape glyphs chunk by chunk + // All glyphs within a chunk have + // the exact same font properties - if (!font) return state; - - shape_options opts; - opts.scale = *st->text_scale; - auto glyphs = font->shape(text_, opts); - - geom::box raw_bbox; - for (auto const & g : glyphs) - raw_bbox |= g.position; - - std::size_t max_lines = 1; - - if (wrap_) + struct glyph_chunk { - max_lines = std::isfinite(bbox[1].length()) - ? std::max(1, std::floor(bbox[1].length() / font->size()[1] / (*st->text_scale))) - : std::numeric_limits::max(); - } + std::size_t end; + text_style style; + }; - std::vector> lines; + std::vector glyphs; + std::vector glyph_chunks; - std::size_t current_glyph = 0; - - for (std::size_t line = 0; line < max_lines; ++line) { - if (line != 0) - ++current_glyph; + geom::point pen{0.f, 0.f}; + shape_options opts; + opts.scale = *st->text_scale; - if (skip_spaces_) + for (auto const & chunk : chunks_) { - while (current_glyph < glyphs.size() && std::isspace(glyphs[current_glyph].character)) - ++current_glyph; - } - - std::size_t line_begin = current_glyph; - std::size_t line_end = line_begin; - - geom::interval x_range; - while (line_end < glyphs.size()) - { - if (is_newline(glyphs[line_end].character)) - break; - - x_range |= glyphs[line_end].position[0]; - - if (x_range.length() > bbox[0].length()) - break; - - ++line_end; - } - - if (line_end < glyphs.size()) - { - std::size_t space_pos = line_end; - while (space_pos > line_begin && !std::isspace(glyphs[space_pos].character)) - --space_pos; - - if (space_pos > line_begin) - line_end = space_pos; - } - - if (line + 1 == max_lines && line_end < glyphs.size()) - { - switch (overflow_) + if (auto text = std::get_if(&chunk)) { - case overflow_mode::ignore: - line_end = glyphs.size(); - break; - case overflow_mode::drop: - glyphs.erase(glyphs.begin() + line_end, glyphs.end()); - break; - case overflow_mode::ellipsis: + if (text->text.find("yo") != std::string::npos) { - std::size_t el_start = std::max(3, line_end) - 3; - - float x_offset = 0.f; - if (el_start > 0) - x_offset = glyphs[el_start - 1].position[0].max; - - char const el_str[] = "..."; - auto els = font->shape(el_str, opts); - for (std::size_t i = el_start; i < line_end; ++i) - { - glyphs[i] = els[i - el_start]; - glyphs[i].position[0] += x_offset; - } - - glyphs.erase(glyphs.begin() + line_end, glyphs.end()); + int x = 42; + (void)x; } - break; + + auto const merged_text_style = *st->text_style | text->style; + + bool const bold = merged_text_style.is_set(text_style_flag::bold); + + auto font = bold ? st->bold_font.get() : st->font.get(); + + auto chunk_glyphs = font->shape(text->text, opts, pen); + + glyphs.reserve(glyphs.size() + chunk_glyphs.size()); + glyphs.insert(glyphs.end(), chunk_glyphs.begin(), chunk_glyphs.end()); + + glyph_chunks.push_back({glyphs.size(), merged_text_style}); } } - - current_glyph = line_end; - lines.push_back({line_begin, line_end}); - if (line_end == glyphs.size()) - break; } - float max_line_size = 0.f; + // Break glyphs into lines - float const underline_width = *(st->text_scale); - float const strikethrough_width = *(st->text_scale); + struct line_range + { + std::size_t begin; + std::size_t end; + }; - std::vector> underline_box(lines.size()); - std::vector> strikethrough_box(lines.size()); + std::vector lines; + { + std::size_t current_glyph = 0; + while (current_glyph < glyphs.size()) + { + if (skip_spaces_) + { + while (current_glyph < glyphs.size() && std::isspace(glyphs[current_glyph].character)) + ++current_glyph; + } + + std::size_t line_begin = current_glyph; + + bool newline_end = false; + bool wrap_end = false; + + geom::interval x_range; + while (current_glyph < glyphs.size()) + { + if (is_newline(glyphs[current_glyph].character)) + { + newline_end = true; + break; + } + + x_range |= glyphs[current_glyph].position[0]; + + if (x_range.length() > bbox[0].length() && wrap_) + { + wrap_end = true; + break; + } + + ++current_glyph; + } + + if (wrap_end && current_glyph > line_begin) + { + std::size_t space_pos = current_glyph - 1; + while (space_pos > line_begin && !std::isspace(glyphs[space_pos].character)) + --space_pos; + + if (space_pos > line_begin) + current_glyph = space_pos; + } + + lines.push_back({line_begin, current_glyph}); + + if (newline_end) + ++current_glyph; + } + } + + // Compute line bboxes + + std::vector> line_bbox(lines.size()); for (std::size_t l = 0; l < lines.size(); ++l) { - geom::interval x_range; - int spaces = 0; + geom::box bbox; + // Glyphs might be smaller than total text height, so + // hardcode font height as min line height + bbox[1] = {0.f, 1.f * font_height}; + for (std::size_t i = lines[l].begin; i < lines[l].end; ++i) + bbox |= glyphs[i].position; + line_bbox[l] = bbox; + } - for (std::size_t i = lines[l].first; i < lines[l].second; ++i) - { - x_range |= glyphs[i].position[0]; - if (std::isspace(glyphs[i].character)) - ++spaces; - } + // Compute total text size - max_line_size = std::max(max_line_size, x_range.length()); + geom::vector text_size{0.f, 0.f}; + for (auto const & l : line_bbox) + { + text_size[0] = std::max(text_size[0], l[0].length()); + text_size[1] += l[1].length(); + } - geom::vector offset; - float space_extra = 0.f; + // TODO: handle text overflow - switch (halign_) - { - case halignment::left: - offset[0] = bbox[0].min - x_range.min; - break; - case halignment::center: - offset[0] = bbox[0].center() - x_range.length() / 2.f - x_range.min; - break; - case halignment::right: - offset[0] = bbox[0].max - x_range.length() - x_range.min; - break; - case halignment::stretch: - offset[0] = bbox[0].min - x_range.min; - if ((l + 1 != lines.size() && text_[lines[l].second] != '\n') && spaces > 0) - space_extra = (bbox[0].length() - x_range.length()) / spaces; - break; - } + // Position lines & glyphs inside label bbox + std::vector> line_offset(lines.size()); + { + float current_y; switch (valign_) { case valignment::top: - offset[1] = bbox[1].min + l * (*st->text_scale) * font->size()[1]; + current_y = bbox[1].min; break; case valignment::center: - offset[1] = bbox[1].center() + (l - lines.size() / 2.f) * (*st->text_scale) * font->size()[1]; + current_y = bbox[1].center() - text_size[1] / 2.f; break; case valignment::bottom: - offset[1] = bbox[1].max + (l - lines.size() * 1.f) * (*st->text_scale) * font->size()[1]; + current_y = bbox[1].max - text_size[1]; break; } - underline_box[l][0] = x_range + offset[0]; - underline_box[l][1].min = offset[1] + font->size()[1] * (*st->text_scale); - underline_box[l][1].max = underline_box[l][1].min + underline_width; - - strikethrough_box[l][0] = x_range + offset[0]; - strikethrough_box[l][1].min = offset[1] + font->size()[1] * (*st->text_scale) / 2.f; - strikethrough_box[l][1].max = strikethrough_box[l][1].min + strikethrough_width; - - for (std::size_t i = lines[l].first; i < lines[l].second; ++i) + for (std::size_t l = 0; l < lines.size(); ++l) { - glyphs[i].position += offset; - if (std::isspace(glyphs[i].character)) - offset[0] += space_extra; + int spaces = 0; + + for (std::size_t i = lines[l].begin; i < lines[l].end; ++i) + if (std::isspace(glyphs[i].character)) + ++spaces; + + geom::vector offset{0.f, current_y}; + float space_extra = 0.f; + + switch (halign_) + { + case halignment::left: + offset[0] = bbox[0].min - line_bbox[l][0].min; + break; + case halignment::center: + offset[0] = bbox[0].center() - line_bbox[l][0].length() / 2.f - line_bbox[l][0].min; + break; + case halignment::right: + offset[0] = bbox[0].max - line_bbox[l][0].length() - line_bbox[l][0].min; + break; + case halignment::stretch: + offset[0] = bbox[0].min - line_bbox[l][0].min; + if ((l + 1 != lines.size() && text_[lines[l].end] != '\n') && spaces > 0) + space_extra = (bbox[0].length() - line_bbox[l][0].length()) / spaces; + break; + } + + line_offset[l] = offset; + + for (std::size_t i = lines[l].begin; i < lines[l].end; ++i) + { + glyphs[i].position += offset; + if (std::isspace(glyphs[i].character)) + offset[0] += space_extra; + } + + current_y += line_bbox[l][1].length(); } } + // Convert chunks into image batches + { - auto & batch = state.batches.emplace_back(); - batch.texture = &font->atlas(); - - for (auto const & g : glyphs) + std::size_t begin = 0; + for (auto const & ch : glyph_chunks) { - auto tc = font->texcoords(g.character); - if (!tc) continue; + auto & batch = state.batches.emplace_back(); - auto & image = batch.images.emplace_back(); - image.position = g.position; - image.position[0] += (std::round(image.position[0].min) - image.position[0].min); - image.position[1] += (std::round(image.position[1].min) - image.position[1].min); - image.texcoords = *tc; + auto font = ch.style.is_set(text_style_flag::bold) ? st->bold_font.get() : st->font.get(); + + batch.texture = &font->atlas(); + + for (std::size_t i = begin; i < ch.end; ++i) + { + auto & g = glyphs[i]; + auto tc = font->texcoords(g.character); + if (!tc) continue; + + g.position[0] += (std::round(g.position[0].min) - g.position[0].min); + g.position[1] += (std::round(g.position[1].min) - g.position[1].min); + + auto & image = batch.images.emplace_back(); + image.position = g.position; + image.texcoords = *tc; + } + + begin = ch.end; } } - if (underline) + // Convert chunks into underline & strikethrough batches + { + float const line_width = 1.f * (*st->text_scale); + auto & batch = state.batches.emplace_back(); batch.texture = single_white_pixel_texture().get(); - for (auto const & u : underline_box) + std::size_t ch_begin = 0; + std::size_t ch = 0; + for (std::size_t l = 0; l < lines.size(); ++l) { - auto & image = batch.images.emplace_back(); - image.position = u; - image.texcoords = {{{0.f, 1.f}, {0.f, 1.f}}}; + float underline_y = std::round(line_offset[l][1] + font_height - line_width); + float strikethrough_y = std::round(line_offset[l][1] + font_height / 2.f - line_width / 2.f); + + std::optional> current_underline; + std::optional> current_strikethrough; + + auto flush_underline = [&]{ + auto & image = batch.images.emplace_back(); + image.position[0] = *current_underline; + image.position[1] = {underline_y, underline_y + line_width}; + current_underline = std::nullopt; + }; + + auto flush_strikethrough = [&]{ + auto & image = batch.images.emplace_back(); + image.position[0] = *current_strikethrough; + image.position[1] = {strikethrough_y, strikethrough_y + line_width}; + current_strikethrough = std::nullopt; + }; + + for (; ch < glyph_chunks.size(); ++ch) + { + auto const & chunk = glyph_chunks[ch]; + + bool const underline = chunk.style.is_set(text_style_flag::underline); + bool const strikethrough = chunk.style.is_set(text_style_flag::strikethrough); + + std::size_t ibegin = std::max(ch_begin, lines[l].begin); + std::size_t iend = std::min(chunk.end, lines[l].end); + + if (ibegin < iend) + { + geom::interval x_range; + for (std::size_t i = ibegin; i < iend; ++i) + x_range |= glyphs[i].position[0]; + + if (underline && current_underline) + { + *current_underline |= x_range; + } + else if (underline && !current_underline) + { + current_underline = x_range; + } + else if (!underline && current_underline) + { + flush_underline(); + } + + if (strikethrough && current_strikethrough) + { + *current_strikethrough |= x_range; + } + else if (strikethrough && !current_strikethrough) + { + current_strikethrough = x_range; + } + else if (!strikethrough && current_strikethrough) + { + flush_strikethrough(); + } + } + + if (chunk.end > lines[l].end) + break; + + ch_begin = chunk.end; + } + + if (current_underline) + flush_underline(); + + if (current_strikethrough) + flush_strikethrough(); } } - if (strikethrough) - { - auto & batch = state.batches.emplace_back(); - batch.texture = single_white_pixel_texture().get(); - - for (auto const & u : strikethrough_box) - { - auto & image = batch.images.emplace_back(); - image.position = u; - image.texcoords = {{{0.f, 1.f}, {0.f, 1.f}}}; - } - } - - state.size[0] = max_line_size; - state.size[1] = lines.size() * (*st->text_scale) * font->size()[1]; + state.size = text_size; return state; }