#include #include #include #include #include #include namespace psemek::ui { label::label(std::string 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{}, std::nullopt, std::nullopt}); on_state_changed(); } static std::unordered_set const known_tags { "bold", "uline", "strike", "color", "link", }; void check_attribute(std::string const & tag, std::optional const & attribute) { if (tag == "bold" || tag == "uline" || tag == "strike") { if (attribute) throw std::runtime_error("tag [" + tag + "] doesn't support attributes"); } else if (tag == "color") { if (!attribute) throw std::runtime_error("tag [color] requires an attribute"); if (!gfx::parse_color(*attribute)) throw std::runtime_error("failed to parse color \"" + std::string(*attribute) + "\""); } else if (tag == "link") { if (!attribute) throw std::runtime_error("tag [link] requires an attribute"); } } void label::set_tagged_text(std::string text) { text_ = std::move(text); selected_link_ = std::nullopt; chunks_.clear(); auto parse_result = tagged_text::parse(text_); if (tag_mapper_) { std::vector mapped; for (auto const & token : parse_result.tokens) { auto replace = tag_mapper_(token); if (replace) mapped.insert(mapped.end(), replace->begin(), replace->end()); else mapped.push_back(token); } parse_result.tokens = std::move(mapped); } std::unordered_map>> tags_stack; for (auto const & token : parse_result.tokens) { if (auto text = std::get_if(&token)) { text_chunk chunk; if (!tags_stack["bold"].empty()) chunk.style.set(text_style_flag::bold); if (!tags_stack["uline"].empty()) chunk.style.set(text_style_flag::underline); if (!tags_stack["strike"].empty()) chunk.style.set(text_style_flag::strikethrough); if (!tags_stack["color"].empty()) chunk.color = *gfx::parse_color(*tags_stack["color"].back()); if (!tags_stack["link"].empty()) chunk.link = tags_stack["link"].back(); chunk.text = *text; chunks_.push_back(chunk); } else if (auto tag = std::get_if(&token)) { if (tag->type == "image") { if (!tag->attribute) throw std::runtime_error("tag [image] requires an attribute"); image_chunk chunk; chunk.id = *tag->attribute; if (!tags_stack["color"].empty()) chunk.color = *gfx::parse_color(*tags_stack["color"].back()); if (!tags_stack["link"].empty()) chunk.link = tags_stack["link"].back(); chunks_.push_back(chunk); } else if (known_tags.contains(tag->type)) { check_attribute(tag->type, tag->attribute); if (tag->type == "link" && !tags_stack["link"].empty()) throw std::runtime_error("tag [link] cannot be nested"); tags_stack[tag->type].push_back(tag->attribute); } else throw std::runtime_error("unknown tag [" + std::string(tag->type) + "]"); } else if (auto tag = std::get_if(&token)) { if (!known_tags.contains(tag->type)) throw std::runtime_error("unknown closing tag [" + std::string(tag->type) + "]"); if (tags_stack[tag->type].empty()) throw std::runtime_error("mismatched opening & closing tags for [" + std::string(tag->type) + "]"); tags_stack[tag->type].pop_back(); } } on_state_changed(); } void label::set_halign(halignment value) { halign_ = value; on_state_changed(); } void label::set_valign(valignment value) { valign_ = value; on_state_changed(); } void label::set_wrap(bool value) { wrap_ = value; on_state_changed(); } void label::set_skip_spaces(bool value) { skip_spaces_ = value; on_state_changed(); } image_provider * label::set_image_provider(struct image_provider * provider) { std::swap(provider, image_provider_); on_state_changed(); return provider; } void label::set_tag_mapper(tagged_text::mapper mapper) { tag_mapper_ = std::move(mapper); } void label::on_link_click(link_callback callback) { link_callback_ = std::move(callback); } void label::set_overflow(overflow_mode value) { overflow_ = value; on_state_changed(); } void label::reshape(geom::box const & bbox) { shape_.box = bbox; cached_state_.reset(); } bool label::on_event(ui::mouse_move const & e) { if (cached_state_) { std::optional new_selected_link; auto const p = geom::cast(e.position); for (auto const & b : cached_state_->link_bboxes) { if (geom::contains(b.first, p)) { new_selected_link = b.second; break; } } if (new_selected_link != selected_link_) { if (new_selected_link) sdl2::set_cursor(sdl2::cursor_type::hand); else sdl2::set_cursor(sdl2::cursor_type::arrow); selected_link_ = new_selected_link; mouse_down_ = false; on_state_changed(); } } return false; } bool label::on_event(ui::mouse_click const & e) { if (e.button == mouse_button::left) { if (e.down) { if (selected_link_) { if (!mouse_down_) { mouse_down_ = true; if (link_callback_) post([cb = link_callback_, text = *selected_link_]{ cb(text); }); on_state_changed(); return true; } } } else { if (mouse_down_) { mouse_down_ = false; on_state_changed(); } } } return false; } geom::box label::size_constraints() const { static float const inf = std::numeric_limits::infinity(); if (!cached_state_inf_) cached_state_inf_ = cached_state_for({{{0.f, inf}, {0.f, inf}}}); return {{{cached_state_inf_->size[0], inf}, {cached_state_inf_->size[1], inf}}}; } geom::interval label::width_constraints(float height) const { static float const inf = std::numeric_limits::infinity(); auto state = cached_state_for({{{0.f, inf}, {0.f, height}}}); return {state.size[0], inf}; } geom::interval label::height_constraints(float width) const { static float const inf = std::numeric_limits::infinity(); auto state = cached_state_for({{{0.f, width}, {0.f, inf}}}); return {state.size[1], inf}; } void label::style_updated() const { element::style_updated(); cached_state_.reset(); cached_state_inf_.reset(); } void label::own_style_updated() const { element::own_style_updated(); cached_state_.reset(); cached_state_inf_.reset(); } void label::draw(painter & p) const { if (!cached_state_) update_cached_state(); auto st = merged_own_style(); if (!st) return; if (st->text_shadow_offset != geom::vector{0, 0}) { auto const offset = geom::cast(*st->text_shadow_offset); for (auto const & batch : cached_state_->batches) { if (!batch.text) continue; auto color = *st->shadow_color; color[3] = (color[3] * 1.f * batch.color[3]) / 255.f; for (auto const & image : batch.images) { if (cached_state_->font_type == font_type::bitmap) p.draw_image(image.position + offset, gfx::texture_view_2d{batch.texture, image.texcoords}, {color, painter::color_mode::multiply}); else if (cached_state_->font_type == font_type::msdf) p.draw_msdf_glyph(image.position + offset, gfx::texture_view_2d{batch.texture, image.texcoords}, cached_state_->sdf_scale, {color}); } } } for (auto const & batch : cached_state_->batches) { for (auto const & image : batch.images) { if (cached_state_->font_type == font_type::bitmap) p.draw_image(image.position, gfx::texture_view_2d{batch.texture, image.texcoords}, {batch.color, batch.text ? painter::color_mode::multiply : painter::color_mode::mix}); else if (cached_state_->font_type == font_type::msdf) p.draw_msdf_glyph(image.position, gfx::texture_view_2d{batch.texture, image.texcoords}, cached_state_->sdf_scale, {batch.color}); } } } void label::on_state_changed() { cached_state_.reset(); cached_state_inf_.reset(); post_reshape(); } static bool is_newline(char32_t c) { return (c == '\n') || (c == '\r'); } void label::update_cached_state() const { cached_state_ = cached_state_for(shape_.box); } label::cached_state label::cached_state_for(geom::box const & bbox) const { auto state = cached_state{}; if (chunks_.empty()) return state; auto st = merged_own_style(); state.font_type = st->font->type(); state.sdf_scale = st->font->sdf_scale(); auto const font_height = st->font->size()[1] * (*st->text_scale); // Shape items (glyphs/images) chunk by chunk // All items within a chunk have // the exact same properties struct item { geom::box position; std::optional character; }; struct item_chunk { std::size_t end; text_style style; gfx::color_rgba color; gfx::texture_view_2d image; std::optional link; }; std::vector items; std::vector item_chunks; { geom::point pen{0.f, 0.f}; shape_options opts; opts.scale = *st->text_scale; for (auto const & chunk : chunks_) { if (auto text = std::get_if(&chunk)) { auto text_style = *st->text_style | text->style; if (text->link) { if (selected_link_ && *text->link == *selected_link_) { if (mouse_down_) text_style |= *st->link_click_style; else text_style |= *st->link_hover_style; } else text_style |= *st->link_style; } gfx::color_rgba color; if (text->color) color = *text->color; else if (text->link) { if (selected_link_ && *text->link == *selected_link_) { if (mouse_down_) color = *st->link_click_color; else color = *st->link_hover_color; } else color = *st->link_color; } else color = *st->text_color; bool const bold = text_style.is_set(text_style_flag::bold); auto font = bold ? st->bold_font.get() : st->font.get(); auto glyphs = font->shape(text->text, opts, pen); items.reserve(items.size() + glyphs.size()); for (auto const & g : glyphs) items.push_back(item{g.position, g.character}); item_chunks.push_back({items.size(), text_style, color, {}, text->link}); } else if (auto image = std::get_if(&chunk)) { auto color = image->color ? *image->color : gfx::color_rgba{255, 255, 255, 0}; if (!image_provider_) throw std::runtime_error("cannot use [image] tag without image provider"); auto texture = image_provider_->get(image->id); float const scale = *st->text_scale; geom::box position; position[0].min = pen[0]; position[0].max = pen[0] + texture.width() * scale; position[1].min = pen[1] + font_height / 2.f - texture.height() * scale / 2.f; position[1].max = position[1].min + texture.height() * scale; pen[0] = position[0].max; items.push_back({position, {}}); item_chunks.push_back({items.size(), {}, color, texture, image->link}); } } } // Break items into lines struct line_range { std::size_t begin; std::size_t end; }; std::vector lines; { std::size_t current_item = 0; while (true) { if (skip_spaces_) { while (current_item < items.size() && items[current_item].character && std::isspace(*items[current_item].character)) ++current_item; } std::size_t line_begin = current_item; bool wrap_end = false; geom::interval x_range; while (current_item < items.size()) { if (items[current_item].character && is_newline(*items[current_item].character)) { break; } x_range |= items[current_item].position[0]; if (x_range.length() > bbox[0].length() && wrap_) { wrap_end = true; break; } ++current_item; } if (wrap_end && current_item == line_begin) break; if (wrap_end && current_item > line_begin) { std::size_t space_pos = current_item - 1; while (space_pos > line_begin && (!items[space_pos].character || !std::isspace(*items[space_pos].character))) --space_pos; if (space_pos > line_begin) current_item = space_pos; } lines.push_back({line_begin, current_item}); if (current_item == items.size()) break; ++current_item; } } // Compute line bboxes std::vector> line_bbox(lines.size()); for (std::size_t l = 0; l < lines.size(); ++l) { 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 |= items[i].position; line_bbox[l] = bbox; } // Compute total text size 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(); } // TODO: handle text overflow // Position lines & items inside label bbox std::vector> line_offset(lines.size()); { float current_y; switch (valign_) { case valignment::top: current_y = bbox[1].min; break; case valignment::center: current_y = bbox[1].center() - text_size[1] / 2.f; break; case valignment::bottom: current_y = bbox[1].max - text_size[1]; break; } for (std::size_t l = 0; l < lines.size(); ++l) { int spaces = 0; for (std::size_t i = lines[l].begin; i < lines[l].end; ++i) if (items[i].character && std::isspace(*items[i].character)) ++spaces; geom::vector offset{0.f, current_y - line_bbox[l][1].min}; 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()) && (lines[l].end >= items.size() || !items[lines[l].end].character || !is_newline(*items[lines[l].end].character)) && 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) { items[i].position += offset; if (items[i].character && std::isspace(*items[i].character)) offset[0] += space_extra; } current_y += line_bbox[l][1].length(); } } // Convert chunks into image batches { std::size_t begin = 0; for (auto const & ch : item_chunks) { auto & batch = state.batches.emplace_back(); if (ch.image) { batch.texture = ch.image.texture; batch.color = ch.color; batch.text = false; for (std::size_t i = begin; i < ch.end; ++i) { auto & g = items[i]; 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 = ch.image.part; } } else { auto font = ch.style.is_set(text_style_flag::bold) ? st->bold_font.get() : st->font.get(); batch.texture = &font->atlas(); batch.color = ch.color; batch.text = true; for (std::size_t i = begin; i < ch.end; ++i) { auto & g = items[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; } } // Convert chunks into underline & strikethrough batches { float const line_width = 1.f * (*st->text_scale); auto get_batch = [&](gfx::color_rgba const & color) -> cached_state::batch & { auto texture = single_white_pixel_texture().get(); if (state.batches.empty() || state.batches.back().texture != texture || state.batches.back().color != color) { auto & b = state.batches.emplace_back(); b.texture = texture; b.color = color; b.text = true; } return state.batches.back(); }; std::size_t ch_begin = 0; std::size_t ch = 0; for (std::size_t l = 0; l < lines.size(); ++l) { 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 last_underline_end; std::optional last_strikethrough_end; for (; ch < item_chunks.size(); ++ch) { auto const & chunk = item_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 |= items[i].position[0]; if (underline) { auto & image = get_batch(chunk.color).images.emplace_back(); image.position[0] = x_range; if (last_underline_end) image.position[0] |= *last_underline_end; image.position[1] = {underline_y, underline_y + line_width}; last_underline_end = image.position[0].max; } else last_underline_end = std::nullopt; if (strikethrough) { auto & image = get_batch(chunk.color).images.emplace_back(); image.position[0] = x_range; if (last_strikethrough_end) image.position[0] |= *last_strikethrough_end; image.position[1] = {strikethrough_y, strikethrough_y + line_width}; last_strikethrough_end = image.position[0].max; } else last_strikethrough_end = std::nullopt; } if (chunk.end > lines[l].end) break; ch_begin = chunk.end; } } } // Generate link bboxes { std::size_t begin = 0; for (auto const & ch : item_chunks) { if (ch.link) { geom::box bbox; for (std::size_t i = begin; i < ch.end; ++i) bbox |= items[i].position; state.link_bboxes.push_back({bbox, *ch.link}); } begin = ch.end; } } state.size = text_size; return state; } std::shared_ptr label::single_white_pixel_texture() const { static std::weak_ptr texture; if (!single_white_pixel_texture_) { std::shared_ptr ptr; if (!(ptr = texture.lock())) { ptr = std::make_shared(); gfx::pixmap_rgba pm({1, 1}, {255, 255, 255, 255}); ptr->load(pm); ptr->nearest_filter(); texture = ptr; } single_white_pixel_texture_ = ptr; } return single_white_pixel_texture_; } }