Skip to content

Commit

Permalink
feat(layouts): text truncation & proper text alignment (#120)
Browse files Browse the repository at this point in the history
feat(layouts): text truncation & proper text alignment

You can now set `truncate` to true in a text element, like this:

```toml
wrap_width = 130.0
truncate = true
```

The text is truncated at the given wrap width instead of wrapped.

While I was truncating text and just generally figuring out what these
imgui functions do, I returned to the problem of correctly-aligning
wrapped text. For right- and center-aligned text, this requires
looping through the full text rendering it one line at a time,
adjusting the position of each line to match its bounds. Further,
because imgui does not export any of its word-wrapping implementation,
we have to do additional work to find word boundaries. To do this, we
look for the nearest space preceding the imgui-selected wrap position.
If it's near enough, we break the line at that position, recalculate
bounds, and draw.

I broke out the text rendering cases so that simple cases do the least
work possible, and the space-seeking and bounds-adjusting work is only
done when required. The implementation of drawing text is somewhat
verbose as a result but each case is simple by itself.

Fixes [#104](#104)
Fixes [[#98](#98)]
  • Loading branch information
ceejbot authored Feb 4, 2024
1 parent 476ecea commit e17cddb
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 28 deletions.
6 changes: 5 additions & 1 deletion docs/article-layouts-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,13 @@ Each slot layout has a list of *text* elements. These describe text that should

Here are the fields a text element has:

- `offset`: Where to draw this text, relative to the center of the slot
- `offset`: Where to draw this text, relative to the center of the slot. This location is the *left edge* of the text box.
- `alignment`: How to justify the text. Possible values are `left`, `center`, and `right`.
- `font_size`: A floating-point number for the size of the type used.
- `color`: The color to use to draw the text.
- `contents`: A format string describing the text to draw.
- `wrap_width`: The maximum allowed width of the text. If set, text is wrapped if it would be longer.
` truncate`: A boolean value (`true` or `false`) indicating if the text should be cut short at the wrap width instead of wrapped. Set this to keep text at one line max.

The data that can be filled into a format string is:

Expand Down Expand Up @@ -147,6 +149,8 @@ color = { r = 255, g = 255, b = 255, a = 255 }
alignment = "left"
contents = "{name}"
font_size = 20.0
wrap_width = 130.0
truncate = false
```

Any additional text elements for the power slot would also be named `[[power.text]]`. The double square brackets tells TOML that this is an [list of items](https://toml.io/en/v1.0.0#array-of-tables). Each new element named that is added to the end of the list.
Expand Down
2 changes: 2 additions & 0 deletions src/layouts/layout_v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ impl HudLayout1 {
contents: "{name}".to_string(),
font_size: slot.name_font_size * factor,
wrap_width: slot.name_wrap_width,
truncate: false,
});
}
if slot.count_color.a > 0 {
Expand All @@ -168,6 +169,7 @@ impl HudLayout1 {
contents: "{count}".to_string(),
font_size: slot.count_font_size * factor,
wrap_width: slot.count_wrap_width,
truncate: false,
});
}

Expand Down
3 changes: 3 additions & 0 deletions src/layouts/layout_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ impl HudLayout2 {
contents: text.contents.clone(),
font_size: text.font_size * scale,
wrap_width: text.wrap_width,
truncate: text.truncate,
}
}

Expand Down Expand Up @@ -265,6 +266,8 @@ pub struct TextElement {
bounds: Option<Point>,
#[serde(default)]
wrap_width: f32,
#[serde(default)]
truncate: bool,
}

#[derive(Debug, Clone, PartialEq, Default)]
Expand Down
6 changes: 6 additions & 0 deletions src/layouts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ impl Layout {
}
}

/// Convert the editable human-facing layout format to the format used by
/// the renderer. This process scales all sizes and translates all locations
/// from relative to absolute in screen space.
pub fn flatten(&self) -> LayoutFlattened {
match self {
// *v dereference the ref-to-box, **v unbox, &**v borrow
Expand All @@ -123,6 +126,7 @@ impl Layout {
}
}

/// Find the coordinates of the layout's location in screen space.
pub fn anchor_point(&self) -> Point {
match self {
Layout::Version1(v) => v.anchor_point(),
Expand All @@ -131,6 +135,8 @@ impl Layout {
}
}

/// An implementation detail of the anchor point calculation, used by both
/// layout formats.
pub fn anchor_point(
global_scale: f32,
size: &Point,
Expand Down
5 changes: 4 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ pub mod plugin {
CircleArc,
}

/// A text element in a form ready to use by the renderer.
#[derive(Clone, Debug)]
pub struct TextFlattened {
anchor: Point,
Expand All @@ -175,10 +176,12 @@ pub mod plugin {
contents: String,
font_size: f32,
wrap_width: f32,
truncate: bool,
}

/// This enum maps key presses to the desired action. More like a C/java
/// enum than a Rust sum type enum.
/// enum than a Rust sum type enum. It's also more like an event name than
/// a key press map at this point.
#[derive(Debug, Clone, Hash)]
enum Action {
/// We do not need to do anything, possibly because the key was not one of our hotkeys.
Expand Down
90 changes: 71 additions & 19 deletions src/renderer/ui_renderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -361,26 +361,82 @@ namespace ui
}
}

void drawText(const std::string text,
const ImVec2 center,
const float fontSize,
const soulsy::Color color,
const Align align,
const float wrapWidth)
void drawText(const std::string text, const ImVec2 center, const TextFlattened* label)
{
if (!text.length() || color.a == 0) { return; }
if (!text.length() || label->color.a == 0) { return; }
const float wrapWidth = label->wrap_width;
const auto align = label->alignment;

auto* font = imFont;
if (!font) { font = ImGui::GetDefaultFont(); }
const ImU32 text_color = IM_COL32(color.r, color.g, color.b, color.a * gHudAlpha);
const ImVec2 bounds = font->CalcTextSizeA(fontSize, wrapWidth, wrapWidth, text.c_str());
ImVec2 alignedCenter = ImVec2(center.x, center.y);
const ImU32 textColor = IM_COL32(label->color.r, label->color.g, label->color.b, label->color.a * gHudAlpha);
ImVec2 lineLeftCorner = ImVec2(center.x, center.y);
const auto* cstr = text.c_str();

// Unrolling our cases here to try to make each pass through do the least
// work it can get away with. Probably needs a re-think.

if (align == Align::Center) { alignedCenter.x += bounds.x * 0.5f; }
else if (align == Align::Right) { alignedCenter.x -= bounds.x; }
// Simple fast case first: no truncation, left alignment. Imgui wraps the
// text for us if wrap width is non-zero.
if (!label->truncate && align == Align::Left)
{
ImGui::GetWindowDrawList()->AddText(
font, label->font_size, lineLeftCorner, textColor, cstr, nullptr, wrapWidth, nullptr);
return;
}

// We're aligning, but not truncating or wrapping, so we can draw one location-adjusted line.
if (!label->truncate && wrapWidth == 0.0f)
{
const ImVec2 bounds = font->CalcTextSizeA(label->font_size, 0.0f, 0.0f, cstr);
if (align == Align::Center) { lineLeftCorner.x = center.x + (wrapWidth - bounds.x) * 0.5f; }
else if (align == Align::Right) { lineLeftCorner.x = center.x + (wrapWidth - bounds.x); }
ImGui::GetWindowDrawList()->AddText(font, label->font_size, lineLeftCorner, textColor, cstr);
return;
}

ImGui::GetWindowDrawList()->AddText(
font, fontSize, alignedCenter, text_color, text.c_str(), nullptr, wrapWidth, nullptr);
// The next fastest cases are truncation cases. We find our truncation point,
// then align that single line by moving the draw location.
if (label->truncate && wrapWidth > 0.0f)
{
const char* remainder = nullptr;
const auto bounds = font->CalcTextSizeA(label->font_size, wrapWidth, 0.0f, cstr, nullptr, &remainder);
if (align == Align::Center) { lineLeftCorner.x = center.x + (wrapWidth - bounds.x) * 0.5f; }
else if (align == Align::Right) { lineLeftCorner.x = center.x + (wrapWidth - bounds.x); }
ImGui::GetWindowDrawList()->AddText(font, label->font_size, lineLeftCorner, textColor, cstr, remainder);
return;
}

// Now we must wrap, not truncate, and align each line as we discover it. We stop
// when we run out of text to draw or we decide we've drawn a ridiculous number of lines.
int loops = 0;
const char* lineToDraw = cstr;
auto lineLoc = ImVec2(center.x, center.y);
const auto length = text.length();

do {
const char* remainder = nullptr;
auto bounds = font->CalcTextSizeA(label->font_size, wrapWidth, 0.0f, lineToDraw, nullptr, &remainder);
if ((remainder < cstr + length) && *remainder != ' ')
{
int adjust = 0;
// magic constant; guess about how much we can shorten without being silly
int furthest = std::min(8, static_cast<int>(strlen(lineToDraw)));
while (adjust < furthest && *(remainder - adjust) != ' ') { adjust++; };
if (*(remainder - adjust) == ' ')
{
remainder -= adjust;
bounds = font->CalcTextSizeA(label->font_size, wrapWidth, 0.0f, lineToDraw, remainder);
}
}
if (*remainder == ' ') { remainder++; }

if (align == Align::Center) { lineLoc.x = center.x + (wrapWidth - bounds.x) * 0.5f; }
else if (align == Align::Right) { lineLoc.x = center.x + (wrapWidth - bounds.x); }
ImGui::GetWindowDrawList()->AddText(font, label->font_size, lineLoc, textColor, lineToDraw, remainder);
lineToDraw = remainder;
lineLoc.y += bounds.y; // move down one line
} while (strlen(lineToDraw) > 0 && ++loops < 5);
}

void ui_renderer::initializeAnimation(const animation_type animation_type,
Expand Down Expand Up @@ -582,11 +638,7 @@ namespace ui
if (label.color.a == 0) { continue; }
const auto textPos = ImVec2(label.anchor.x, label.anchor.y);
auto entrytxt = std::string(entry->fmtstr(label.contents));
// Let's try a wrap width here. This is going to be wrong, but we'll experiment.
if (!entrytxt.empty())
{
drawText(entrytxt, textPos, label.font_size, label.color, label.alignment, label.wrap_width);
}
if (!entrytxt.empty()) { drawText(entrytxt, textPos, &label); }
}
}

Expand Down
8 changes: 1 addition & 7 deletions src/renderer/ui_renderer.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,7 @@ namespace ui
const ImVec2 size,
const float angle,
const ImU32 im_color); // retaining support for animations...
void drawText(const std::string text,
const ImVec2 center,
const float font_size,
const soulsy::Color color,
const Align alignment);
void drawText(const std::string text, const ImVec2 center, const TextFlattened* label);
void drawMeterCircleArc(float level, SlotFlattened slotLayout);
void drawMeterRectangular(float level, SlotFlattened slotLayout);
ImVec2 rotateVector(const ImVec2 vector, const float angle);
Expand All @@ -76,8 +72,6 @@ namespace ui

ui_renderer();

// Oxidation section.
// older...
static void initializeAnimation(animation_type animation_type,
float a_screen_x,
float a_screen_y,
Expand Down
1 change: 1 addition & 0 deletions src/soulsy.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ namespace soulsy
struct SlotFlattened;
struct SlotLayout;
struct SpellData;
struct TextFlattened;
}

using namespace soulsy;

0 comments on commit e17cddb

Please sign in to comment.