diff --git a/builtin/ui/elem.lua b/builtin/ui/elem.lua index dde3ddb80..04e201687 100644 --- a/builtin/ui/elem.lua +++ b/builtin/ui/elem.lua @@ -45,6 +45,8 @@ function ui.Elem:new(param) end function ui.Elem:_init(props) + self._label = ui._opt(props.label, "string") + self._groups = {} self._children = {} @@ -114,6 +116,10 @@ function ui.Elem:_encode_fields() ui._encode_flag(fl, "Z", ui._encode_array("z", child_ids)) end + if ui._shift_flag(fl, self._label) then + ui._encode_flag(fl, "s", self._label) + end + self:_encode_box(fl, self._boxes.main) return ui._encode_flags(fl) diff --git a/builtin/ui/style.lua b/builtin/ui/style.lua index 767702964..7297396ee 100644 --- a/builtin/ui/style.lua +++ b/builtin/ui/style.lua @@ -82,6 +82,18 @@ local icon_place_map = { bottom = 4, } +local align_map = { + left = 0, + center = 1, + right = 2, +} + +local valign_map = { + top = 0, + center = 1, + bottom = 2, +} + local function opt_color(val, def) assert(val == nil or core.colorspec_to_int(val)) return val or def @@ -130,6 +142,22 @@ local function cascade_layer(new, add, props, p) ui._opt(add[p.."_frame_time"], "number", props[p.."_frame_time"]) end +local function cascade_text(new, add, props) + new.prepend = ui._opt(add.prepend, "string", props.prepend) + new.append = ui._opt(add.append, "string", props.append) + + new.text_color = opt_color(add.text_color, props.text_color) + new.text_mark = opt_color(add.text_mark, props.text_mark) + new.text_size = ui._opt(add.text_size, "number", props.text_size) + + new.text_mono = ui._opt(add.text_mono, "boolean", props.text_mono) + new.text_italic = ui._opt(add.text_italic, "boolean", props.text_italic) + new.text_bold = ui._opt(add.text_bold, "boolean", props.text_bold) + + new.text_align = ui._opt_enum(add.text_align, align_map, props.text_align) + new.text_valign = ui._opt_enum(add.text_valign, valign_map, props.text_valign) +end + function ui._cascade_props(add, props) local new = {} @@ -148,6 +176,8 @@ function ui._cascade_props(add, props) new.icon_gutter = ui._opt(add.icon_gutter, "number", props.icon_gutter) new.icon_overlap = ui._opt(add.icon_overlap, "boolean", props.icon_overlap) + cascade_text(new, add, props) + return new end @@ -243,6 +273,40 @@ local function encode_layer(props, p) return fl end +local function encode_text(props) + local fl = ui._make_flags() + + if ui._shift_flag(fl, props.prepend) then + ui._encode_flag(fl, "s", props.prepend) + end + if ui._shift_flag(fl, props.append) then + ui._encode_flag(fl, "s", props.append) + end + + if ui._shift_flag(fl, props.text_color) then + ui._encode_flag(fl, "I", core.colorspec_to_int(props.text_color)) + end + if ui._shift_flag(fl, props.text_mark) then + ui._encode_flag(fl, "I", core.colorspec_to_int(props.text_mark)) + end + if ui._shift_flag(fl, props.text_size) then + ui._encode_flag(fl, "I", props.text_size) + end + + ui._shift_flag_bool(fl, props.text_mono) + ui._shift_flag_bool(fl, props.text_italic) + ui._shift_flag_bool(fl, props.text_bold) + + if ui._shift_flag(fl, props.text_align) then + ui._encode_flag(fl, "B", align_map[props.text_align]) + end + if ui._shift_flag(fl, props.text_valign) then + ui._encode_flag(fl, "B", valign_map[props.text_valign]) + end + + return fl +end + local function encode_subflags(fl, sub_fl) if ui._shift_flag(fl, sub_fl.flags ~= 0) then ui._encode_flag(fl, "s", ui._encode_flags(sub_fl)) @@ -277,5 +341,7 @@ function ui._encode_props(props) end ui._shift_flag_bool(fl, props.icon_overlap) + encode_subflags(fl, encode_text(props)) + return ui._encode("s", ui._encode_flags(fl)) end diff --git a/src/ui/box.cpp b/src/ui/box.cpp index 86d1f59c4..c4bcec3e0 100644 --- a/src/ui/box.cpp +++ b/src/ui/box.cpp @@ -7,6 +7,7 @@ #include "debug.h" #include "log.h" #include "porting.h" +#include "client/fontengine.h" #include "ui/elem.h" #include "ui/manager.h" #include "ui/window.h" @@ -29,11 +30,16 @@ namespace ui void Box::reset() { m_content.clear(); + m_label = ""; + m_style.reset(); for (State i = 0; i < m_style_refs.size(); i++) { m_style_refs[i] = NO_STYLE; } + + m_text = L""; + m_font = nullptr; } void Box::read(std::istream &full_is) @@ -93,6 +99,15 @@ namespace ui } } + // Now that we have updated text style properties, we can update our + // cached text string and font object. + m_text = utf8_to_wide(m_style.text.prepend) + utf8_to_wide(m_label) + + utf8_to_wide(m_style.text.append); + + FontSpec spec(m_style.text.size, m_style.text.mono ? FM_Mono : FM_Standard, + m_style.text.bold, m_style.text.italic); + m_font = g_fontengine->getFont(spec); + // Since our box has been restyled, the previously computed layout // information is no longer valid. m_min_layout = SizeF(); @@ -140,7 +155,7 @@ namespace ui { if (m_style.display != DisplayMode::HIDDEN) { drawBox(); - drawIcon(); + drawItems(); } for (Box *box : m_content) { @@ -294,6 +309,11 @@ namespace ui void Box::resizeBox() { + // First, we need to expand the minimum size of the box to accommodate + // the size of any text it might contain. + SizeF text_size = getWindow().getTextSize(m_font, m_text); + m_min_content = m_min_content.max(text_size); + // If the box is set to clip its contents in either dimension, we can // set the minimum content size to zero for that coordinate. if (m_style.layout.clip == DirFlags::X || m_style.layout.clip == DirFlags::BOTH) { @@ -627,13 +647,19 @@ namespace ui } } - void Box::drawIcon() + void Box::drawItems() { // The icon rect is computed while the box is being laid out, so we // just need to draw it with the fill color behind it. getWindow().drawRect(m_icon_rect, m_clip_rect, m_style.icon.fill); getWindow().drawTexture(m_icon_rect, m_clip_rect, m_style.icon.image, getLayerSource(m_style.icon), m_style.icon.tint); + + // The window handles all the complicated text layout, so we can just + // draw the text with all the appropriate styling. + getWindow().drawText(m_content_rect, m_clip_rect, m_font, m_text, + m_style.text.color, m_style.text.mark, + m_style.text.align, m_style.text.valign); } bool Box::isHovered() const diff --git a/src/ui/box.h b/src/ui/box.h index d2530138c..963a2aea8 100644 --- a/src/ui/box.h +++ b/src/ui/box.h @@ -58,10 +58,15 @@ namespace ui u32 m_item; std::vector m_content; + std::string_view m_label; Style m_style; std::array m_style_refs; + // We cache the font and text content every time the box is restyled. + std::wstring m_text; + gui::IGUIFont *m_font; + // Cached information about the layout of the box, which is cleared in // restyle() and recomputed in resize() and relayout(). SizeF m_min_layout; @@ -97,6 +102,9 @@ namespace ui const std::vector &getContent() const { return m_content; } void setContent(std::vector content) { m_content = std::move(content); } + std::string_view getLabel() const { return m_label; } + void setLabel(std::string_view label) { m_label = label; } + void reset(); void read(std::istream &is); @@ -125,7 +133,7 @@ namespace ui void relayoutPlace(); void drawBox(); - void drawIcon(); + void drawItems(); bool isHovered() const; bool isPressed() const; diff --git a/src/ui/elem.cpp b/src/ui/elem.cpp index 4f2ffc2a1..82bffedc4 100644 --- a/src/ui/elem.cpp +++ b/src/ui/elem.cpp @@ -61,6 +61,7 @@ namespace ui m_parent = nullptr; m_children.clear(); + m_label = ""; m_main_box.reset(); m_events = 0; @@ -72,6 +73,8 @@ namespace ui if (testShift(set_mask)) readChildren(is); + if (testShift(set_mask)) + m_label = readStr16(is); if (testShift(set_mask)) m_main_box.read(is); @@ -79,7 +82,9 @@ namespace ui for (Elem *elem : m_children) { content.push_back(&elem->getMain()); } + m_main_box.setContent(std::move(content)); + m_main_box.setLabel(m_label); } bool Elem::isFocused() const diff --git a/src/ui/elem.h b/src/ui/elem.h index 39c0f63c7..fdeb49c23 100644 --- a/src/ui/elem.h +++ b/src/ui/elem.h @@ -49,6 +49,8 @@ namespace ui Elem *m_parent; std::vector m_children; + std::string m_label; + Box m_main_box; u64 m_hovered_box = Box::NO_ID; // Persistent u64 m_pressed_box = Box::NO_ID; // Persistent diff --git a/src/ui/helpers.h b/src/ui/helpers.h index 6bf90f4a8..040d24674 100644 --- a/src/ui/helpers.h +++ b/src/ui/helpers.h @@ -8,6 +8,7 @@ #include "util/serialize.h" #include +#include #include #include #include diff --git a/src/ui/style.cpp b/src/ui/style.cpp index b0e92482d..6b1970682 100644 --- a/src/ui/style.cpp +++ b/src/ui/style.cpp @@ -43,6 +43,14 @@ namespace ui return (IconPlace)place; } + static Align toAlign(u8 align) + { + if (align > (u8)Align::MAX) { + return Align::CENTER; + } + return (Align)align; + } + void Layout::reset() { type = LayoutType::PLACE; @@ -134,6 +142,50 @@ namespace ui frame_time = std::max(readU32(is), 1U); } + void Text::reset() + { + prepend = ""; + append = ""; + + color = WHITE; + mark = BLANK; + size = 16; + + mono = false; + italic = false; + bold = false; + + align = Align::CENTER; + valign = Align::CENTER; + } + + void Text::read(std::istream &full_is) + { + auto is = newIs(readStr16(full_is)); + u32 set_mask = readU32(is); + + if (testShift(set_mask)) + prepend = readStr16(is); + if (testShift(set_mask)) + append = readStr16(is); + + if (testShift(set_mask)) + color = readARGB8(is); + if (testShift(set_mask)) + mark = readARGB8(is); + if (testShift(set_mask)) + size = std::clamp(readU32(is), 1U, 999U); + + testShiftBool(set_mask, mono); + testShiftBool(set_mask, italic); + testShiftBool(set_mask, bold); + + if (testShift(set_mask)) + align = toAlign(readU8(is)); + if (testShift(set_mask)) + valign = toAlign(readU8(is)); + } + void Style::reset() { layout.reset(); @@ -150,6 +202,8 @@ namespace ui icon_place = IconPlace::CENTER; icon_gutter = 0.0f; icon_overlap = false; + + text.reset(); } void Style::read(std::istream &is) @@ -181,5 +235,8 @@ namespace ui if (testShift(set_mask)) icon_gutter = readF32(is); testShiftBool(set_mask, icon_overlap); + + if (testShift(set_mask)) + text.read(is); } } diff --git a/src/ui/style.h b/src/ui/style.h index bde469365..9d2b330dd 100644 --- a/src/ui/style.h +++ b/src/ui/style.h @@ -53,6 +53,16 @@ namespace ui MAX = BOTTOM, }; + // Serialized enum; do not change order of entries. + enum class Align : u8 + { + START, + CENTER, + END, + + MAX = END, + }; + struct Layout { LayoutType type; @@ -101,6 +111,28 @@ namespace ui void read(std::istream &is); }; + struct Text + { + std::string prepend; + std::string append; + + video::SColor color; + video::SColor mark; + u32 size; + + bool mono; + bool italic; + bool bold; + + Align align; + Align valign; + + Text() { reset(); } + + void reset(); + void read(std::istream &is); + }; + struct Style { Layout layout; @@ -118,6 +150,8 @@ namespace ui float icon_gutter; bool icon_overlap; + Text text; + Style() { reset(); } void reset(); diff --git a/src/ui/window.cpp b/src/ui/window.cpp index 8b9c65aa4..44613dbfa 100644 --- a/src/ui/window.cpp +++ b/src/ui/window.cpp @@ -162,6 +162,41 @@ namespace ui return PosF(x, y) / getScale(); } + SizeF Window::getTextSize(gui::IGUIFont *font, std::wstring_view text) + { + // If we have an empty string, we want it to take up no space. IGUIFont + // measures the dimensions of an empty string as having the normal line + // height rather than no space. + if (text.empty()) { + return SizeF(); + } + + // IGUIFont measures the height of text with newlines incorrectly, so + // we have to measure each line in the string individually. + SizeF text_size = SizeF(); + size_t start = 0; + + while (start <= text.size()) { + // Get the line spanning from the start of this line to the next + // newline, or the end of the string if there are no more newlines. + size_t end = std::min(text.find(L'\n', start), text.size()); + std::wstring line(text.substr(start, end - start)); + + // Get the dimensions of the line. Since fonts are already scaled, + // we have to reverse the scaling factor to get the right size. + SizeF line_size = SizeF(font->getDimension( + unescape_enriched(line).c_str())) / getScale(); + + text_size.W = std::max(text_size.W, line_size.W); + text_size.H += line_size.H; + + // Move the start of the current line to after the end of this one. + start = end + 1; + } + + return text_size; + } + void Window::drawRect(RectF dst, RectF clip, video::SColor color) { if (dst.intersectWith(clip).empty() || color.getAlpha() == 0) { @@ -189,6 +224,81 @@ namespace ui src * DispF(getTextureSize(texture)), &scaled_clip, colors, true); } + void Window::drawText(RectF dst, RectF clip, gui::IGUIFont *font, + std::wstring_view text, video::SColor color, video::SColor mark, + Align align, Align valign) + { + if (dst.intersectWith(clip).empty() || font == nullptr || text.empty()) { + return; + } + + // We count the number of lines in the text to find the total height. + size_t num_lines = std::count(text.begin(), text.end(), L'\n') + 1; + + // Get the height of a single line, and use this with the vertical + // alignment to find the vertical position of the first line. + float height = font->getDimension(L"").Height / getScale(); + float top; + + switch (valign) { + case Align::START: + top = dst.T; + break; + case Align::CENTER: + top = (dst.T + dst.B - (height * num_lines)) / 2.0f; + break; + case Align::END: + top = dst.B - (height * num_lines); + break; + } + + core::recti scaled_clip = clip * getScale(); + + // Like getTextSize(), we loop over each line in the string. + size_t start = 0; + + while (start <= text.size()) { + size_t end = std::min(text.find(L'\n', start), text.size()); + std::wstring line(text.substr(start, end - start)); + + // Get the width of this line of text. Just like the height, we use + // the alignment to find the horizontal position for this line. + float width = font->getDimension( + unescape_enriched(line).c_str()).Width / getScale(); + float left; + + switch (align) { + case Align::START: + left = dst.L; + break; + case Align::CENTER: + left = (dst.L + dst.R - width) / 2.0f; + break; + case Align::END: + left = dst.R - width; + break; + } + + // This gives us the destination rect for this line of the text, + // which we scale appropriately. + RectF line_rect = RectF(PosF(left, top), SizeF(width, height)) * getScale(); + + // If we have a highlight color for the text, draw the highlight + // before we draw the line. + if (mark.getAlpha() != 0) { + RenderingEngine::get_video_driver()->draw2DRectangle( + mark, line_rect, &scaled_clip); + } + + // Then draw the text itself using the provided font. + font->draw(line.c_str(), line_rect, color, false, false, &scaled_clip); + + // Finally, advance to the next line. + top += height; + start = end + 1; + } + } + void Window::drawAll() { Box &backdrop = m_root_elem->getBackdrop(); diff --git a/src/ui/window.h b/src/ui/window.h index 7a528d4eb..d309251dd 100644 --- a/src/ui/window.h +++ b/src/ui/window.h @@ -98,9 +98,14 @@ namespace ui SizeF getScreenSize() const; PosF getPointerPos() const; + SizeF getTextSize(gui::IGUIFont *font, std::wstring_view text); + void drawRect(RectF dst, RectF clip, video::SColor color); void drawTexture(RectF dst, RectF clip, video::ITexture *texture, RectF src = RectF(0.0f, 0.0f, 1.0f, 1.0f), video::SColor tint = WHITE); + void drawText(RectF dst, RectF clip, gui::IGUIFont *font, std::wstring_view text, + video::SColor color = WHITE, video::SColor mark = BLANK, + Align align = Align::START, Align valign = Align::START); void drawAll();