mirror of
https://github.com/steredbits/pelagia.git
synced 2026-10-18 09:01:32 +02:00
257 lines
7.3 KiB
C++
257 lines
7.3 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2026 steredbits and Pelagia contributors
|
|
#include "ui/text_renderer.h"
|
|
|
|
#include <cmath>
|
|
|
|
#include "ui/embedded_fonts.h"
|
|
#include "util/log.h"
|
|
#include "util/text.h"
|
|
|
|
namespace ui {
|
|
|
|
namespace {
|
|
|
|
const char kEllipsis[] = "\xE2\x80\xA6"; // U+2026
|
|
constexpr int kGlyphPadding = 1; // avoids filtering overflow
|
|
|
|
uint64_t glyph_key(Weight weight, int px, uint32_t cp) {
|
|
return (static_cast<uint64_t>(weight) << 56) | (static_cast<uint64_t>(px & 0xFFFF) << 32) |
|
|
cp;
|
|
}
|
|
|
|
std::string trim_right(std::string s) {
|
|
while (!s.empty() && s.back() == ' ') {
|
|
s.pop_back();
|
|
}
|
|
return s;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TextRenderer::TextRenderer(Canvas* canvas, int atlas_size)
|
|
: canvas_(canvas), atlas_size_(atlas_size) {}
|
|
|
|
TextRenderer::~TextRenderer() {
|
|
if (atlas_ != 0) {
|
|
canvas_->destroy_texture(atlas_);
|
|
}
|
|
}
|
|
|
|
bool TextRenderer::init() {
|
|
const bool ok = fonts_[0].load(kNotoSansRegular, kNotoSansRegularSize) &&
|
|
fonts_[1].load(kNotoSansBold, kNotoSansBoldSize);
|
|
if (!ok) {
|
|
LOG_ERROR("Unreadable embedded fonts");
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
int TextRenderer::line_height(Weight weight, int px) const {
|
|
int a = 0, d = 0, g = 0;
|
|
font(weight).vertical_metrics(px, &a, &d, &g);
|
|
return a - d + g;
|
|
}
|
|
|
|
int TextRenderer::ascent(Weight weight, int px) const {
|
|
int a = 0, d = 0, g = 0;
|
|
font(weight).vertical_metrics(px, &a, &d, &g);
|
|
return a;
|
|
}
|
|
|
|
bool TextRenderer::covers(uint32_t cp, Weight weight) const {
|
|
return font(weight).glyph_index(cp) != 0;
|
|
}
|
|
|
|
void TextRenderer::reset_atlas() {
|
|
glyphs_.clear();
|
|
pack_x_ = pack_y_ = shelf_h_ = 0;
|
|
++atlas_resets_;
|
|
LOG_DEBUG("Glyph atlas full: emptied (%d)", atlas_resets_);
|
|
}
|
|
|
|
// Shelf packing: left to right, new shelf when the row
|
|
// is full. False if the atlas is full.
|
|
bool TextRenderer::pack(int w, int h, int* x, int* y) {
|
|
if (w + kGlyphPadding > atlas_size_ || h + kGlyphPadding > atlas_size_) {
|
|
return false;
|
|
}
|
|
if (pack_x_ + w + kGlyphPadding > atlas_size_) {
|
|
pack_x_ = 0;
|
|
pack_y_ += shelf_h_;
|
|
shelf_h_ = 0;
|
|
}
|
|
if (pack_y_ + h + kGlyphPadding > atlas_size_) {
|
|
return false;
|
|
}
|
|
*x = pack_x_;
|
|
*y = pack_y_;
|
|
pack_x_ += w + kGlyphPadding;
|
|
if (h + kGlyphPadding > shelf_h_) {
|
|
shelf_h_ = h + kGlyphPadding;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const TextRenderer::Glyph* TextRenderer::glyph(Weight weight, int px, uint32_t cp) {
|
|
if (util::is_invisible_char(cp)) {
|
|
return nullptr;
|
|
}
|
|
const Font& f = font(weight);
|
|
if (!f.loaded()) {
|
|
return nullptr;
|
|
}
|
|
const uint64_t key = glyph_key(weight, px, cp);
|
|
auto it = glyphs_.find(key);
|
|
if (it != glyphs_.end()) {
|
|
return &it->second;
|
|
}
|
|
Glyph g;
|
|
g.index = f.glyph_index(cp);
|
|
if (g.index == 0) {
|
|
g.index = f.glyph_index(util::kReplacementChar);
|
|
}
|
|
g.advance = f.advance(g.index, px);
|
|
f.rasterize(g.index, px, &scratch_, &g.w, &g.h, &g.xoff, &g.yoff);
|
|
if (g.w > 0) {
|
|
if (atlas_ == 0) {
|
|
atlas_ = canvas_->create_texture(atlas_size_, atlas_size_, PixelFormat::Alpha8);
|
|
if (atlas_ == 0) {
|
|
return nullptr;
|
|
}
|
|
// Initial content undefined: we start from a transparent atlas.
|
|
const std::vector<uint8_t> zero(static_cast<size_t>(atlas_size_) * atlas_size_, 0);
|
|
canvas_->update_texture(atlas_, 0, 0, atlas_size_, atlas_size_, zero.data(),
|
|
atlas_size_);
|
|
}
|
|
if (!pack(g.w, g.h, &g.x, &g.y)) {
|
|
reset_atlas();
|
|
if (!pack(g.w, g.h, &g.x, &g.y)) {
|
|
return nullptr; // glyph bigger than the atlas
|
|
}
|
|
}
|
|
canvas_->update_texture(atlas_, g.x, g.y, g.w, g.h, scratch_.data(), g.w);
|
|
}
|
|
return &(glyphs_[key] = g);
|
|
}
|
|
|
|
template <typename Fn>
|
|
float TextRenderer::layout(const std::string& text, Weight weight, int px, Fn fn) {
|
|
float pen = 0.0f;
|
|
int previous = 0;
|
|
for (size_t pos = 0; pos < text.size();) {
|
|
const uint32_t cp = util::utf8_next(text, &pos);
|
|
const Glyph* g = glyph(weight, px, cp);
|
|
if (!g) {
|
|
continue;
|
|
}
|
|
if (previous != 0) {
|
|
pen += font(weight).kerning(previous, g->index, px);
|
|
}
|
|
fn(*g, pen);
|
|
pen += g->advance;
|
|
previous = g->index;
|
|
}
|
|
return pen;
|
|
}
|
|
|
|
int TextRenderer::measure(const std::string& text, Weight weight, int px) {
|
|
return static_cast<int>(std::ceil(layout(text, weight, px, [](const Glyph&, float) {})));
|
|
}
|
|
|
|
int TextRenderer::draw(const std::string& text, int x, int y, Weight weight, int px,
|
|
Color color) {
|
|
const int baseline = y + ascent(weight, px);
|
|
const float width = layout(text, weight, px, [&](const Glyph& g, float pen) {
|
|
if (g.w > 0) {
|
|
const int gx = x + static_cast<int>(std::lround(pen)) + g.xoff;
|
|
canvas_->draw_texture(atlas_, rect(g.x, g.y, g.w, g.h),
|
|
rect(gx, baseline + g.yoff, g.w, g.h), color);
|
|
}
|
|
});
|
|
return static_cast<int>(std::ceil(width));
|
|
}
|
|
|
|
std::string TextRenderer::ellipsize(const std::string& text, Weight weight, int px,
|
|
int max_width) {
|
|
if (measure(text, weight, px) <= max_width) {
|
|
return text;
|
|
}
|
|
const int budget = max_width - measure(kEllipsis, weight, px);
|
|
std::string prefix;
|
|
for (size_t pos = 0; pos < text.size();) {
|
|
util::utf8_next(text, &pos);
|
|
if (measure(text.substr(0, pos), weight, px) > budget) {
|
|
break;
|
|
}
|
|
prefix = text.substr(0, pos);
|
|
}
|
|
return trim_right(prefix) + kEllipsis;
|
|
}
|
|
|
|
std::vector<std::string> TextRenderer::wrap(const std::string& text, Weight weight, int px,
|
|
int max_width, int max_lines) {
|
|
std::vector<std::string> lines;
|
|
std::string line;
|
|
size_t pos = 0;
|
|
// End of text not placed when the number of lines is reached.
|
|
size_t rest_start = std::string::npos;
|
|
const char* rest_join = " "; // "" if the cut falls inside a word
|
|
while (pos < text.size()) {
|
|
// Next word (single spaces: the text is already cleaned).
|
|
size_t end = text.find(' ', pos);
|
|
if (end == std::string::npos) {
|
|
end = text.size();
|
|
}
|
|
const std::string word = text.substr(pos, end - pos);
|
|
const std::string candidate = line.empty() ? word : line + " " + word;
|
|
if (measure(candidate, weight, px) <= max_width) {
|
|
line = candidate;
|
|
pos = end + 1;
|
|
continue;
|
|
}
|
|
if (!line.empty()) {
|
|
if (static_cast<int>(lines.size()) + 1 == max_lines) {
|
|
rest_start = pos;
|
|
break;
|
|
}
|
|
lines.push_back(line);
|
|
line.clear();
|
|
continue;
|
|
}
|
|
// Single word too long: cut at the last character that fits.
|
|
size_t cut = pos;
|
|
for (size_t p = pos; p < end;) {
|
|
size_t next = p;
|
|
util::utf8_next(text, &next);
|
|
if (measure(text.substr(pos, next - pos), weight, px) > max_width && cut > pos) {
|
|
break;
|
|
}
|
|
cut = next;
|
|
p = next;
|
|
}
|
|
line = text.substr(pos, cut - pos);
|
|
pos = cut;
|
|
if (pos < end) {
|
|
if (static_cast<int>(lines.size()) + 1 == max_lines) {
|
|
rest_start = pos;
|
|
rest_join = "";
|
|
break;
|
|
}
|
|
lines.push_back(line);
|
|
line.clear();
|
|
} else {
|
|
pos = end + 1;
|
|
}
|
|
}
|
|
if (rest_start != std::string::npos) {
|
|
// Last allowed line: what fits, then "...".
|
|
lines.push_back(ellipsize(line + rest_join + text.substr(rest_start), weight, px, max_width));
|
|
} else if (!line.empty()) {
|
|
lines.push_back(line);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
} // namespace ui
|