diff --git a/libs/ui/include/psemek/ui/label.hpp b/libs/ui/include/psemek/ui/label.hpp index bcdfa2a8..489dd681 100644 --- a/libs/ui/include/psemek/ui/label.hpp +++ b/libs/ui/include/psemek/ui/label.hpp @@ -62,9 +62,16 @@ namespace psemek::ui virtual struct image_provider * set_image_provider(struct image_provider * provider); virtual struct image_provider * image_provider() const { return image_provider_; } + using link_callback = std::function; + + virtual void on_link_click(link_callback callback); + struct shape const & shape() const override { return shape_; } void reshape(geom::box const & bbox) override; + bool on_event(ui::mouse_move const & e) override; + bool on_event(ui::mouse_click const & e) override; + geom::box size_constraints() const override; geom::interval width_constraints(float height) const override; @@ -89,6 +96,8 @@ namespace psemek::ui struct image_provider * image_provider_ = nullptr; + link_callback link_callback_; + box_shape shape_; struct text_chunk @@ -96,12 +105,14 @@ namespace psemek::ui std::string_view text; text_style style; // to be OR'd with base style std::optional color; + std::optional link; }; struct image_chunk { std::string_view id; std::optional color; + std::optional link; }; using chunk = std::variant; @@ -127,10 +138,14 @@ namespace psemek::ui std::vector batches; geom::vector size{0.f, 0.f}; + + std::vector, std::string_view>> link_bboxes; }; mutable std::optional cached_state_; mutable std::optional cached_state_inf_; + std::optional selected_link_; + bool mouse_down_ = false; void update_cached_state() const; cached_state cached_state_for(geom::box const & bbox) const; diff --git a/libs/ui/include/psemek/ui/style.hpp b/libs/ui/include/psemek/ui/style.hpp index c21e9daa..3f5e4a61 100644 --- a/libs/ui/include/psemek/ui/style.hpp +++ b/libs/ui/include/psemek/ui/style.hpp @@ -58,9 +58,17 @@ namespace psemek::ui std::optional text_color; std::optional text_scale; + std::optional text_style; std::optional> text_shadow_offset; - std::optional text_style; + std::optional link_color; + std::optional link_style; + + std::optional link_hover_color; + std::optional link_hover_style; + + std::optional link_click_color; + std::optional link_click_style; std::shared_ptr font; std::shared_ptr bold_font; diff --git a/libs/ui/source/label.cpp b/libs/ui/source/label.cpp index 4087a15e..9d2f44fa 100644 --- a/libs/ui/source/label.cpp +++ b/libs/ui/source/label.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include #include @@ -17,7 +19,7 @@ namespace psemek::ui text_ = std::move(text); chunks_.clear(); - chunks_.push_back(text_chunk{{text_.data(), text_.size()}, text_style{}, std::nullopt}); + chunks_.push_back(text_chunk{{text_.data(), text_.size()}, text_style{}, std::nullopt, std::nullopt}); on_state_changed(); } @@ -28,6 +30,7 @@ namespace psemek::ui "uline", "strike", "color", + "link", }; void check_attribute(std::string_view tag, std::optional const & attribute) @@ -44,11 +47,17 @@ namespace psemek::ui 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(); @@ -69,6 +78,8 @@ namespace psemek::ui 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); } @@ -82,11 +93,15 @@ namespace psemek::ui 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 @@ -136,6 +151,11 @@ namespace psemek::ui return provider; } + void label::on_link_click(link_callback callback) + { + link_callback_ = std::move(callback); + } + void label::set_overflow(overflow_mode value) { overflow_ = value; @@ -148,6 +168,63 @@ namespace psemek::ui 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_) + { + 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(); @@ -256,6 +333,7 @@ namespace psemek::ui text_style style; gfx::color_rgba color; gfx::texture_view_2d image; + std::optional link; }; std::vector items; @@ -270,21 +348,50 @@ namespace psemek::ui { if (auto text = std::get_if(&chunk)) { - auto const merged_text_style = *st->text_style | text->style; + auto text_style = *st->text_style | text->style; - bool const bold = merged_text_style.is_set(text_style_flag::bold); + 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 color = text->color ? *text->color : *st->text_color; - 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(), merged_text_style, color, {}}); + item_chunks.push_back({items.size(), text_style, color, {}, text->link}); } else if (auto image = std::get_if(&chunk)) { @@ -306,7 +413,7 @@ namespace psemek::ui pen[0] = position[0].max; items.push_back({position, {}}); - item_chunks.push_back({items.size(), {}, color, texture}); + item_chunks.push_back({items.size(), {}, color, texture, image->link}); } } } @@ -590,6 +697,24 @@ namespace psemek::ui } } + // 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; diff --git a/libs/ui/source/style.cpp b/libs/ui/source/style.cpp index e02c71ee..0ad9618d 100644 --- a/libs/ui/source/style.cpp +++ b/libs/ui/source/style.cpp @@ -71,8 +71,14 @@ namespace psemek::ui merge(dst.outer_margin, src.outer_margin); merge(dst.text_color, src.text_color); merge(dst.text_scale, src.text_scale); - merge(dst.text_shadow_offset, src.text_shadow_offset); merge(dst.text_style, src.text_style); + merge(dst.text_shadow_offset, src.text_shadow_offset); + merge(dst.link_color, src.link_color); + merge(dst.link_style, src.link_style); + merge(dst.link_hover_color, src.link_hover_color); + merge(dst.link_hover_style, src.link_hover_style); + merge(dst.link_click_color, src.link_click_color); + merge(dst.link_click_style, src.link_click_style); merge(dst.font, src.font); merge(dst.bold_font, src.bold_font); } @@ -125,9 +131,17 @@ namespace psemek::ui s.text_color = {255, 255, 255, 255}; s.text_scale = 1; + s.text_style = text_style{}; s.text_shadow_offset = {1, 1}; - s.text_style = text_style{}; + s.link_color = {0, 0, 255, 255}; + s.link_style = text_style{}; + + s.link_hover_color = {127, 127, 255, 255}; + s.link_hover_style = text_style{}; + + s.link_click_color = {0, 0, 127, 255}; + s.link_click_style = text_style{}; return s; }