Files
AyeZeeBB 28231c0df2 Typing dots: none for commands and private messages, and no flood of stop notices
A line that starts with "/" is a command, a whisper or party chat, so typing
one no longer shows dots to the whole server. Dots already showing are cleared
when the line becomes one.

A "stopped typing" notice was relayed every time it arrived, while "typing"
was held to one per 200 ms, so a modified client could flood everyone through
the host. A lobby's host and a dedicated server now pass a stop on only when
that player's dots are up, which is at most once per accepted "typing".
2026-10-12 23:45:27 -05:00

900 lines
50 KiB
C++

#include "overlay_internal.h"
#include "chat_emotes.h"
#include "chat_rich.h"
#include "nametag_gradient.h"
#include "role_badge.h"
#include <imgui_internal.h>
#include <optional>
// Multiplayer text chat in the bottom-right corner. Closed, the newest lines
// show for a few seconds and fade, taking no input. T opens it (overlay_input.cpp):
// the whole session log with a text box, Enter sends and closes, Esc closes.
// Lines come from the session through a small feed rather than the full model,
// so they can be polled on every presented frame.
namespace dingosdk::overlay {
namespace {
std::atomic<ChatFeed> chat_feed{};
}
void set_chat_feed(ChatFeed feed) noexcept { chat_feed.store(feed); }
} // namespace dingosdk::overlay
using namespace dingosdk::overlay::detail;
namespace dingosdk::overlay::detail {
namespace {
using Clock = std::chrono::steady_clock;
using namespace std::chrono_literals;
constexpr auto chat_hold = 10s, chat_fade = 1s;
constexpr std::size_t closed_lines = 6;
struct ChatState {
MultiplayerChat feed;
Clock::time_point next_poll{};
std::uint64_t seen{}; // newest sequence already given an arrival time
// When each line reached this overlay; the session keeps no clock for them.
std::deque<std::pair<std::uint64_t, Clock::time_point>> arrivals;
std::uint64_t feed_version{}; // counts polls of `feed`; the caches below are rebuilt after one
std::array<char, multiplayer_chat_max_bytes + 1> input{};
// Typing dots: the input as last seen while the box was open, and when it last changed.
bool typing_watch{}, typing_on{}, typing_changed_valid{};
std::string typing_seen;
Clock::time_point typing_changed{}, typing_sent{};
bool scroll_to_bottom{};
std::vector<std::string> completions; // "/" commands the typed text can still become
std::uint64_t completions_version{}; // the Suggestions::version `completions` was copied from
// What the text in the box can become (suggestions()): worked out again only when that text,
// the feed or the loaded emotes change, not several times every frame.
struct Suggestions {
bool valid{};
std::string typed;
std::uint64_t feed{};
bool emotes_loaded{};
std::uint64_t version{}; // counts rebuilds
std::vector<std::string> arguments; // argument_completions
std::vector<const MultiplayerChatCommand*> commands; // matching_commands: into `feed`, until its next poll
bool emote{}; // the last word is an unfinished :emote
std::string before; // everything ahead of it
std::vector<std::string> emote_names; // the emotes it can become
std::vector<std::string> completions; // what Tab cycles through
} suggestions;
bool refocus_input{}; // an emote was clicked: back to the text box
bool cursor_to_end{}; // the box was filled for the player: type after it
// Tab cycling: the text as typed before the first Tab, the options it offered, the one in the
// box now and its text. Typing anything else ends the cycle.
struct Cycle {
std::string typed, written;
std::vector<std::string> options;
std::size_t index{};
bool active{};
} cycle;
};
ChatState& chat() {
static ChatState value;
return value;
}
Clock::time_point arrived(const ChatState& c, std::uint64_t sequence) {
for (const auto& [line, at] : c.arrivals)
if (line == sequence) return at;
return {};
}
// The sender's role colour, as their nametag shows it; ReSkate's notices muted. Lines without
// a role (older sessions) fall back to a steady colour per sender.
ImU32 name_colour(const MultiplayerChatLine& line) {
if (line.server) return line.color ? line.color : multiplayer::nametag_server;
if (!line.sender) return theme::muted;
if (line.color) return line.color;
if (line.local) return theme::blue;
constexpr std::array<ImU32, 6> palette{IM_COL32(255, 196, 0, 255), IM_COL32(99, 212, 113, 255),
IM_COL32(255, 122, 122, 255), IM_COL32(190, 146, 255, 255), IM_COL32(94, 205, 250, 255),
IM_COL32(255, 160, 90, 255)};
auto mixed = line.sender * 0x9E3779B97F4A7C15ull;
mixed ^= mixed >> 29;
return palette[mixed % palette.size()];
}
// A message's own colour: the server's lines in lavender, everyone else's white.
ImU32 text_colour(const MultiplayerChatLine& line) {
return line.text_color ? line.text_color : line.server ? multiplayer::nametag_server_text : theme::paper;
}
// "name:"; a role tag is drawn in its own box before it (role_badge.h).
std::string chat_label(const MultiplayerChatLine& line) { return line.name + ":"; }
// What Tab offers for a command's argument: "/tp zee" -> the players starting "zee". Each is the
// whole text the box would hold. Empty when `typed` is not a command with such an argument.
std::vector<std::string> argument_completions(const MultiplayerChat& feed, std::string_view typed) {
std::vector<std::string> result;
if (typed.empty() || typed.front() != '/') return result;
std::string lowered(typed);
for (auto& ch : lowered) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
static const std::vector<std::string> times{"default", "morning", "noon", "afternoon", "evening", "night",
"weatherday", "weathernight"};
for (const auto& command : feed.commands) {
if (command.argument.empty() || !lowered.starts_with(command.name + " ")) continue;
const auto& values = command.argument == "player" ? feed.players : command.argument == "map" ? feed.maps : times;
const auto partial = lowered.substr(command.name.size() + 1);
for (const auto& value : values) {
std::string folded(value);
for (auto& ch : folded) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
if (folded.starts_with(partial) && folded != partial) result.push_back(command.name + " " + value);
}
break;
}
return result;
}
// The "/" commands `typed` can still become (or is already giving arguments to).
std::vector<const MultiplayerChatCommand*> matching_commands(const MultiplayerChat& feed, std::string_view typed) {
std::vector<const MultiplayerChatCommand*> result;
if (typed.empty() || typed.front() != '/') return result;
std::string lowered(typed);
for (auto& ch : lowered) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
for (const auto& command : feed.commands)
if (command.name.starts_with(lowered) || lowered.starts_with(command.name + " ")) result.push_back(&command);
return result;
}
// Messages with their :emote: images: chat_rich.h, shared with the chat bubbles.
using namespace chat_rich;
// A chat line as drawn: its label, whether it has emotes and their layout, kept per line
// (sequences never repeat) for the font, size, width and indent it was laid out with, instead
// of laid out again every frame.
struct LineLayout {
std::size_t length{}; // the line's text: the same line, to be sure
std::string label; // chat_label
bool laid_out{};
const ImFont* font{};
float size{}, wrap{}, indent{};
bool emotes_loaded{};
bool emotes{}; // has_emotes; without any the text is drawn plainly
std::string text; // what is drawn: the line's text with any emote the filter masked put back
RichText rich; // its layout, when it has
ImVec2 extent{}; // its size: the layout's, or the plain text's wrapped at `wrap` (the closed view)
};
using LineLayouts = std::map<std::uint64_t, LineLayout>;
struct ChatLayouts {
LineLayouts open, closed; // the open panel's lines and the closed view's fading ones
};
ChatLayouts& chat_layouts() {
static ChatLayouts value;
return value;
}
LineLayout& line_layout(LineLayouts& layouts, const MultiplayerChatLine& line) {
auto& entry = layouts[line.sequence];
if (entry.label.empty() || entry.length != line.text.size()) {
entry = {};
entry.length = line.text.size();
entry.label = chat_label(line);
}
return entry;
}
const LineLayout& layout_for(LineLayout& entry, const MultiplayerChatLine& line, ImFont* font, float size, float wrap,
float indent) {
const bool loaded = chat_emotes_loaded();
if (entry.laid_out && entry.font == font && entry.size == size && entry.wrap == wrap && entry.indent == indent &&
entry.emotes_loaded == loaded)
return entry;
entry.laid_out = true;
entry.font = font;
entry.size = size;
entry.wrap = wrap;
entry.indent = indent;
entry.emotes_loaded = loaded;
entry.text = restore_emotes(line.text, line.unmasked);
entry.emotes = has_emotes(entry.text);
entry.rich = entry.emotes ? lay_out(font, size, entry.text, wrap, indent) : RichText{};
entry.extent = entry.emotes ? ImVec2(wrap, entry.rich.height) : font->CalcTextSizeA(size, FLT_MAX, wrap, entry.text.c_str());
return entry;
}
// The emote being typed: the last word when it is ":" and at least two letters, not closed yet.
// `before` is everything ahead of it.
std::optional<std::string_view> typed_emote(std::string_view input, std::string_view& before) {
const auto space = input.find_last_of(' ');
const auto word = space == std::string_view::npos ? input : input.substr(space + 1);
before = input.substr(0, input.size() - word.size());
if (word.empty() || word.front() != ':' || word.find(':', 1) != std::string_view::npos || !chat_emotes_loaded())
return std::nullopt;
return word.substr(1);
}
// Tab: the first command the typed text can become, with a space for its argument.
// Also puts the cursor after text already in the box when the chat opens (a draft, or the "/" it
// was opened with) and after an emote the player clicked.
int complete_command(ImGuiInputTextCallbackData* data) {
auto* c = static_cast<ChatState*>(data->UserData);
if (!c) return 0;
if (data->EventFlag == ImGuiInputTextFlags_CallbackAlways) {
if (c->cursor_to_end) {
c->cursor_to_end = false;
data->CursorPos = data->SelectionStart = data->SelectionEnd = data->BufTextLen;
}
return 0;
}
if (data->EventFlag != ImGuiInputTextFlags_CallbackCompletion || c->completions.empty()) return 0;
auto& cycle = c->cycle;
const bool back = ImGui::GetIO().KeyShift;
if (!cycle.active) {
cycle.active = true;
cycle.typed.assign(data->Buf, static_cast<std::size_t>(data->BufTextLen));
cycle.options = c->completions;
cycle.index = back ? cycle.options.size() - 1 : 0;
} else {
const auto count = cycle.options.size();
cycle.index = back ? (cycle.index + count - 1) % count : (cycle.index + 1) % count;
}
cycle.written = cycle.options[cycle.index];
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, cycle.written.c_str());
return 0;
}
// What the suggestion boxes show and Tab cycles through: while cycling, the options of the text
// as it was typed before the first Tab; otherwise those of the text in the box.
std::string suggesting_for(const ChatState& c) { return c.cycle.active ? c.cycle.typed : std::string(c.input.data()); }
bool highlighted(const ChatState& c, const std::string& option, std::size_t index) {
return c.cycle.active ? option == c.cycle.written : index == 0;
}
// The commands, values and emotes `typed` can become, and what Tab cycles through for it. Built
// again only when `typed`, the feed (polled every 100 ms) or the loaded emotes change: the chat
// panel, its command list and the emote picker all ask every frame.
const ChatState::Suggestions& suggestions(ChatState& c, const std::string& typed) {
auto& found = c.suggestions;
const bool loaded = chat_emotes_loaded();
if (found.valid && found.feed == c.feed_version && found.emotes_loaded == loaded && found.typed == typed) return found;
found.valid = true;
found.typed = typed;
found.feed = c.feed_version;
found.emotes_loaded = loaded;
++found.version;
found.arguments = argument_completions(c.feed, typed);
found.commands = matching_commands(c.feed, typed);
std::string_view before;
const auto emote = typed_emote(typed, before);
found.emote = emote.has_value();
found.before.assign(before);
found.emote_names = emote ? chat_emote_names(*emote, 1024) : std::vector<std::string>{};
// Tab: a command's players, maps or times; otherwise the commands the text can still become
// (not one already typed in full); and the emotes after a ":".
found.completions = found.arguments;
if (found.completions.empty())
for (const auto* command : found.commands)
if (command->name.size() > typed.size()) found.completions.push_back(command->name);
for (const auto& name : found.emote_names) found.completions.push_back(found.before + ":" + name + ":");
return found;
}
// Whether `text` is `before` + ":" + `name` + ":", without building that string.
bool is_emote_text(std::string_view text, std::string_view before, std::string_view name) {
return text.size() == before.size() + name.size() + 2 && text.starts_with(before) && text[before.size()] == ':' &&
text.substr(before.size() + 1, name.size()) == name && text.back() == ':';
}
// The session relays this. Dots mean the input changed in the last three seconds, not that
// the box is merely open. A failed queue is tried again next frame. A "/" line is a command,
// a private message or party chat: not everyone's business, so it shows no dots.
void note_typing(ChatState& c, bool open) {
const auto now = Clock::now();
if (!open) {
if (c.typing_on && queue_multiplayer_action("typing", "0")) c.typing_on = false;
c.typing_watch = false;
c.typing_changed_valid = false;
return;
}
const std::string_view text(c.input.data());
if (const auto first = text.find_first_not_of(' '); first != std::string_view::npos && text[first] == '/') {
if (c.typing_on && queue_multiplayer_action("typing", "0")) c.typing_on = false;
c.typing_seen.assign(text);
c.typing_watch = true;
c.typing_changed_valid = false;
return;
}
if (!c.typing_watch) {
c.typing_seen.assign(text);
c.typing_watch = true;
} else if (text != c.typing_seen) {
c.typing_seen.assign(text);
c.typing_changed = now;
c.typing_changed_valid = true;
}
const bool active = c.typing_changed_valid && now - c.typing_changed < 3s;
if (active) {
if ((!c.typing_on || now - c.typing_sent >= 500ms) && queue_multiplayer_action("typing", "1")) {
c.typing_on = true;
c.typing_sent = now;
}
} else if (c.typing_on && queue_multiplayer_action("typing", "0")) {
c.typing_on = false;
}
}
ImU32 with_alpha(ImU32 colour, float alpha) {
const auto base = static_cast<float>((colour >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f;
return (colour & ~IM_COL32_A_MASK) |
(static_cast<ImU32>(base * std::clamp(alpha, 0.0f, 1.0f) * 255.0f + 0.5f) << IM_COL32_A_SHIFT);
}
} // namespace
bool chat_pending() {
auto& s = state();
auto& c = chat();
const auto now = Clock::now();
if (now >= c.next_poll) {
c.next_poll = now + 100ms;
if (const auto feed = chat_feed.load()) {
try { c.feed = feed(); } catch (...) { c.feed = {}; }
++c.feed_version;
// Layouts of lines the feed no longer holds: all older than its first (a new
// session's lines come after every earlier one).
for (auto* layouts : {&chat_layouts().open, &chat_layouts().closed}) {
if (c.feed.lines.empty()) layouts->clear();
else layouts->erase(layouts->begin(), layouts->lower_bound(c.feed.lines.front().sequence));
}
}
s.chat_available.store(c.feed.available);
// Leaving the session closes an open chat box.
if (!c.feed.available) s.chat_visible.store(false);
for (const auto& line : c.feed.lines)
if (line.sequence > c.seen) {
c.arrivals.emplace_back(line.sequence, now);
c.scroll_to_bottom = true;
}
c.seen = std::max(c.seen, c.feed.latest);
while (c.arrivals.size() > multiplayer_chat_history) c.arrivals.pop_front();
}
note_typing(c, s.chat_visible.load() && c.feed.available);
if (s.chat_visible.load()) return true;
if (!c.feed.available) return false;
if (c.feed.vote.id || c.feed.announcement.id) return true; // the vote and announcement cards
for (const auto& [line, at] : c.arrivals)
if (now - at < chat_hold + chat_fade) return true;
return false;
}
// The "/" commands matching what is typed, in a box just above the open chat panel.
// The emotes matching what is being typed (":ka"), with their images, above the chat panel.
// The emote picker: from a lone ":", a grid of every emote (narrowed by the letters typed after
// it) just above the open chat panel. Clicking one writes it in; Tab takes the first.
void emote_picker(ChatState& c, ImFont* heading, ImVec2 bottom_left, float width, float scale) {
const auto& found = suggestions(c, suggesting_for(c));
if (!found.emote) return;
// Nothing below asks for the suggestions again, so these stay put while the grid draws.
const auto& names = found.emote_names;
const std::string_view before = found.before;
if (names.empty()) return;
const float pad = 8.0f * scale, cell = 36.0f * scale, image = 28.0f * scale, label = 22.0f * scale;
const int columns = std::max(1, static_cast<int>((width - pad * 2.0f) / cell));
const int rows = (static_cast<int>(names.size()) + columns - 1) / columns;
const float grid = std::min(rows, 5) * cell;
ImGui::SetNextWindowPos(bottom_left, ImGuiCond_Always, ImVec2(0.0f, 1.0f));
ImGui::SetNextWindowSize(ImVec2(width, pad * 2.0f + grid + label), ImGuiCond_Always);
ImGui::PushStyleColor(ImGuiCol_WindowBg, with_alpha(theme::ink, 0.94f));
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, with_alpha(theme::blue, 0.35f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, with_alpha(theme::blue, 0.6f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f * scale);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2((cell - image) * 0.5f, (cell - image) * 0.5f));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f));
constexpr auto flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoScrollbar;
if (ImGui::Begin("##reskate_emote_picker", nullptr, flags)) {
std::string shown = names.front();
ImGui::BeginChild("##emote_grid", ImVec2(0.0f, grid), ImGuiChildFlags_None);
for (std::size_t i = 0; i < names.size(); ++i) {
if (i % static_cast<std::size_t>(columns)) ImGui::SameLine();
ImGui::PushID(static_cast<int>(i));
const bool current = c.cycle.active && is_emote_text(c.cycle.written, before, names[i]);
if (current) {
shown = names[i];
ImGui::PushStyleColor(ImGuiCol_Button, with_alpha(theme::blue, 0.45f));
}
ChatEmote emote;
bool clicked{};
if (chat_emote(names[i], emote)) {
// Wide emotes keep their shape inside the square cell.
const float w = std::min(image * emote.aspect, image), h = w / emote.aspect;
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2((cell - w) * 0.5f, (cell - h) * 0.5f));
clicked = ImGui::ImageButton("##emote", emote.texture, ImVec2(w, h), emote.uv0, emote.uv1);
ImGui::PopStyleVar();
} else {
clicked = ImGui::Button("?", ImVec2(cell, cell));
}
if (current) {
ImGui::PopStyleColor();
ImGui::SetScrollHereY(0.5f);
}
if (ImGui::IsItemHovered()) shown = names[i];
if (clicked) {
const auto inserted = std::string(before) + ":" + names[i] + ":";
c.input.fill(0);
std::memcpy(c.input.data(), inserted.data(), std::min(inserted.size(), c.input.size() - 1));
c.refocus_input = true;
}
ImGui::PopID();
}
ImGui::EndChild();
ImGui::PushFont(heading);
ImGui::TextUnformatted((":" + shown + ":").c_str());
ImGui::PopFont();
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, theme::muted);
ImGui::TextUnformatted((" " + std::to_string(names.size()) + (names.size() == 1 ? " emote" : " emotes") +
" | click one, or Tab through them").c_str());
ImGui::PopStyleColor();
}
ImGui::End();
ImGui::PopStyleVar(4);
ImGui::PopStyleColor(4);
}
// The first row to show so that row `current` of `count` is among the `limit` shown.
std::size_t first_shown(std::size_t current, std::size_t count, std::size_t limit) {
if (count <= limit || current < limit) return 0;
return std::min(current + 1 - limit, count - limit);
}
void draw_command_list(ChatState& c, ImFont* heading, ImFont* body, ImVec2 bottom_left, float width, float scale) {
const auto& found = suggestions(c, suggesting_for(c));
const auto& arguments = found.arguments;
if (!arguments.empty()) {
// The values Tab fills in: players, maps or times.
constexpr std::size_t limit = 8;
std::size_t current{};
for (std::size_t i = 0; i < arguments.size(); ++i)
if (highlighted(c, arguments[i], i)) current = i;
const auto first = first_shown(current, arguments.size(), limit);
const float size = 15.0f * scale, pad = 8.0f * scale, row = size + 4.0f * scale;
const auto rows = std::min(arguments.size(), limit);
const float height = pad * 2 + row * static_cast<float>(rows) + (arguments.size() > limit ? row : 0.0f);
auto* draw = ImGui::GetForegroundDrawList();
const ImVec2 min(bottom_left.x, bottom_left.y - height), max(bottom_left.x + width, bottom_left.y);
draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.94f), 4.0f * scale);
float y = min.y + pad;
for (std::size_t i = first; i < first + rows; ++i, y += row)
draw->AddText(heading, size, ImVec2(min.x + pad, y), i == current ? theme::blue : theme::paper, arguments[i].c_str());
if (arguments.size() > limit)
draw->AddText(body, size * 0.9f, ImVec2(min.x + pad, y), theme::muted,
(std::to_string(current + 1) + " of " + std::to_string(arguments.size()) +
"; Tab for the next, Shift+Tab back").c_str());
return;
}
const auto& matches = found.commands;
if (matches.empty()) return;
constexpr std::size_t shown_limit = 8;
std::size_t current{};
for (std::size_t i = 0; i < matches.size(); ++i)
if (highlighted(c, matches[i]->name, i)) current = i;
const auto first = first_shown(current, matches.size(), shown_limit);
const float size = 15.0f * scale, pad = 8.0f * scale, gap = 4.0f * scale;
const auto rows = std::min(matches.size(), shown_limit);
const float row = size + gap;
const float height = pad * 2 + row * static_cast<float>(rows) - gap + (matches.size() > shown_limit ? row : 0.0f);
auto* draw = ImGui::GetForegroundDrawList();
const ImVec2 min(bottom_left.x, bottom_left.y - height), max(bottom_left.x + width, bottom_left.y);
draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.94f), 4.0f * scale);
float y = min.y + pad;
for (std::size_t i = first; i < first + rows; ++i) {
const auto& command = *matches[i];
const auto usage_extent = heading->CalcTextSizeA(size, FLT_MAX, 0.0f, command.usage.c_str());
draw->AddText(heading, size, ImVec2(min.x + pad, y), i == current ? theme::blue : theme::paper, command.usage.c_str());
draw->PushClipRect(ImVec2(min.x + pad + usage_extent.x + 10.0f * scale, y), ImVec2(max.x - pad, y + row), true);
draw->AddText(body, size * 0.92f, ImVec2(min.x + pad + usage_extent.x + 10.0f * scale, y + size * 0.05f),
theme::muted, command.description.c_str());
draw->PopClipRect();
y += row;
}
if (matches.size() > shown_limit)
draw->AddText(body, size * 0.9f, ImVec2(min.x + pad, y), theme::muted,
(std::to_string(current + 1) + " of " + std::to_string(matches.size()) +
"; Tab for the next, Shift+Tab back").c_str());
}
// Card text with its :emote: images (chat_rich.h), measured and drawn as the chat panel's
// lines are; text without any known emote is drawn plainly, as before.
struct CardText {
bool emotes{};
RichText rich;
ImVec2 extent;
};
CardText card_text(ImFont* font, float size, const std::string& text, float wrap, std::size_t max_lines = 0,
float emote_scale = 1.3f) {
CardText out;
out.emotes = has_emotes(text);
if (out.emotes) {
out.rich = lay_out(font, size, text, wrap, 0.0f, max_lines, emote_scale);
out.extent = ImVec2(out.rich.width, out.rich.height);
} else {
out.extent = font->CalcTextSizeA(size, FLT_MAX, wrap, text.c_str());
}
return out;
}
void draw_card_text(ImDrawList* draw, ImFont* font, float size, ImVec2 at, const std::string& text, const CardText& laid,
ImU32 colour, float wrap) {
if (laid.emotes) draw_rich(draw, font, size, at, text, laid.rich, colour, 1.0f);
else draw->AddText(font, size, at, colour, text.c_str(), nullptr, wrap);
}
// The vote a dedicated server is running, or has just finished: a card on the middle of the
// screen's right edge, in the look of the overlay's notices (a dark tile with a strip of colour
// down its left). It says what is voted on, the tally and the time left, and has Yes and No. The
// player answers with their binds (the keycaps on the buttons; F1 and F2 unless changed), or by
// clicking while the cursor is free.
// A poll on the same card: its question, then a row for each answer with its count and a bar of
// its share. The player answers with the number key on the row's keycap, by clicking a row while
// the cursor is free, or with /1, /2... in chat; their answer is the blue row.
void draw_poll(const MultiplayerVote& poll, ImFont* heading, ImFont* body, ImVec2 display, float scale, bool clickable) {
const bool running = poll.outcome == 0;
const ImU32 accent = running ? theme::blue : poll.outcome == 3 ? theme::muted : skate_theme::good;
const char* title = running ? "POLL" : poll.outcome == 3 ? "POLL CANCELLED" : "POLL ENDED";
const float width = 360.0f * scale, margin = 24.0f * scale, strip = 5.0f * scale;
const float pad = 14.0f * scale, gap = 8.0f * scale, title_size = 13.0f * scale, size = 18.0f * scale, fine = 13.0f * scale;
const float row = 30.0f * scale, between = 4.0f * scale, inner = width - strip - pad * 2.0f;
const auto question = card_text(heading, size, poll.label, inner);
const auto question_extent = question.extent;
const bool answers = running && poll.may_vote;
const auto rows = static_cast<float>(poll.answers.size());
const float height = pad + title_size + gap + question_extent.y + gap + rows * row + std::max(0.0f, rows - 1.0f) * between +
(answers ? gap + fine : 0.0f) + pad;
const ImVec2 min(display.x - margin - width, std::floor((display.y - height) * 0.5f)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.92f));
draw->AddRectFilled(min, ImVec2(min.x + strip, max.y), accent);
const float left = min.x + strip + pad, right = max.x - pad;
float y = min.y + pad;
draw->AddText(heading, title_size, ImVec2(left, y), accent, title);
if (running) {
const auto time = std::to_string(poll.seconds) + " s";
const auto extent = heading->CalcTextSizeA(title_size, FLT_MAX, 0.0f, time.c_str());
draw->AddText(heading, title_size, ImVec2(right - extent.x, y), theme::paper, time.c_str());
}
y += title_size + gap;
draw_card_text(draw, heading, size, ImVec2(left, y), poll.label, question, theme::paper, inner);
y += question_extent.y + gap;
unsigned total{}, most{};
for (const auto count : poll.counts) total += count, most = std::max(most, count);
for (std::size_t i = 0; i < poll.answers.size(); ++i) {
const auto count = i < poll.counts.size() ? poll.counts[i] : 0U;
const ImVec2 a(left, y), b(right, y + row);
const bool chosen = poll.mine == i + 1;
const bool hovered = answers && clickable && ImGui::IsMouseHoveringRect(a, b, false);
draw->AddRectFilled(a, b, hovered ? skate_theme::tile_light : skate_theme::tile_grey);
// Its share of the answers as a bar along the bottom; once ended, the winner's is green.
const float share = total ? static_cast<float>(count) / static_cast<float>(total) : 0.0f;
const ImU32 fill = chosen ? theme::blue : !running && count && count == most ? skate_theme::good : theme::muted;
if (share > 0.0f) draw->AddRectFilled(ImVec2(a.x, b.y - 3.0f * scale), ImVec2(a.x + (b.x - a.x) * share, b.y), fill);
if (chosen) draw->AddRectFilled(a, ImVec2(a.x + 3.0f * scale, b.y), theme::blue);
const auto number = std::to_string(i + 1);
const float text_y = y + (row - fine) * 0.5f - 1.0f * scale;
// The key that gives this answer, as the game shows its own keys.
{
const float cap = 18.0f * scale;
const ImVec2 at(a.x + 6.0f * scale, y + (row - cap) * 0.5f);
const auto extent = heading->CalcTextSizeA(12.0f * scale, FLT_MAX, 0.0f, number.c_str());
draw->AddRectFilled(at, ImVec2(at.x + cap, at.y + cap), chosen ? theme::blue : theme::paper, 3.0f * scale);
draw->AddText(heading, 12.0f * scale, ImVec2(at.x + (cap - extent.x) * 0.5f, at.y + (cap - extent.y) * 0.5f),
chosen ? theme::paper : skate_theme::black, number.c_str());
}
const auto counted = std::to_string(count);
const float count_width = heading->CalcTextSizeA(fine, FLT_MAX, 0.0f, counted.c_str()).x;
draw->PushClipRect(ImVec2(a.x + 32.0f * scale, a.y), ImVec2(b.x - count_width - 18.0f * scale, b.y), true);
// One line, cut with "..." where the count starts; an emote is a little smaller here, so it fits the row.
const float answer_size = fine + 1.0f * scale;
const float answer_wrap = (b.x - count_width - 18.0f * scale) - (a.x + 32.0f * scale);
const auto answer = card_text(body, answer_size, poll.answers[i], answer_wrap, 1, 1.15f);
const float answer_y = answer.emotes ? y + (row - answer.extent.y) * 0.5f : text_y;
draw_card_text(draw, body, answer_size, ImVec2(a.x + 32.0f * scale, answer_y), poll.answers[i], answer, theme::paper, 0.0f);
draw->PopClipRect();
draw->AddText(heading, fine, ImVec2(b.x - 10.0f * scale - count_width, text_y), theme::paper, counted.c_str());
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) queue_multiplayer_action("vote", number);
y += row + between;
}
if (answers) {
const auto hint = "Press 1 to " + std::to_string(poll.answers.size()) + " on the keyboard, or click an answer.";
draw->AddText(body, fine, ImVec2(left, y - between + gap), theme::muted, hint.c_str());
}
}
// A dedicated server's announcement: a card at the top middle of the screen in the same look,
// blue down its left, for as long as the server shows it.
void draw_announcement(const MultiplayerAnnouncement& announcement, ImFont* heading, ImFont* body, ImVec2 display, float scale) {
if (!announcement.id || announcement.text.empty()) return;
const float width = std::min(520.0f * scale, display.x - 48.0f * scale), strip = 5.0f * scale;
const float pad = 14.0f * scale, gap = 6.0f * scale, title_size = 13.0f * scale, size = 18.0f * scale;
const float inner = width - strip - pad * 2.0f;
const auto text = card_text(body, size, announcement.text, inner);
const auto text_extent = text.extent;
const float height = pad + title_size + gap + text_extent.y + pad;
const ImVec2 min(std::floor((display.x - width) * 0.5f), 24.0f * scale), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.92f));
draw->AddRectFilled(min, ImVec2(min.x + strip, max.y), theme::blue);
const float left = min.x + strip + pad;
draw->AddText(heading, title_size, ImVec2(left, min.y + pad), theme::blue, "ANNOUNCEMENT");
draw_card_text(draw, body, size, ImVec2(left, min.y + pad + title_size + gap), announcement.text, text, theme::paper, inner);
}
void draw_vote(const MultiplayerVote& vote, ImFont* heading, ImFont* body, ImVec2 display, float scale, bool clickable) {
if (!vote.id) return;
if (vote.next_map && draw_map_vote(vote)) return;
if (vote.poll) return draw_poll(vote, heading, body, display, scale, clickable);
constexpr ImU32 green = skate_theme::good, red = skate_theme::danger;
const bool running = vote.outcome == 0;
const ImU32 accent = running ? theme::blue : vote.outcome == 1 ? green : vote.outcome == 2 ? red : theme::muted;
const char* title = running ? "VOTE" : vote.outcome == 1 ? "VOTE PASSED" : vote.outcome == 2 ? "VOTE FAILED" : "VOTE CANCELLED";
std::string question = vote.label;
if (!question.empty() && question.front() >= 'a' && question.front() <= 'z') question.front() = static_cast<char>(question.front() - 32);
if (running) question += '?';
const float width = 360.0f * scale, margin = 24.0f * scale, strip = 5.0f * scale;
const float pad = 14.0f * scale, gap = 8.0f * scale, title_size = 13.0f * scale, size = 18.0f * scale, fine = 13.0f * scale;
const float button_height = 34.0f * scale, bar = 10.0f * scale, inner = width - strip - pad * 2.0f;
const auto question_extent = heading->CalcTextSizeA(size, FLT_MAX, inner, question.c_str());
const bool answers = running && vote.may_vote;
const float height = pad + title_size + gap + question_extent.y + gap + bar + gap + fine +
(running ? gap + (answers ? button_height : fine) : 0.0f) + pad;
const ImVec2 min(display.x - margin - width, std::floor((display.y - height) * 0.5f)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
draw->AddRectFilled(min, max, with_alpha(theme::ink, 0.92f));
draw->AddRectFilled(min, ImVec2(min.x + strip, max.y), accent);
const float left = min.x + strip + pad, right = max.x - pad;
float y = min.y + pad;
draw->AddText(heading, title_size, ImVec2(left, y), accent, title);
if (running) {
const auto time = std::to_string(vote.seconds) + " s";
const auto extent = heading->CalcTextSizeA(title_size, FLT_MAX, 0.0f, time.c_str());
draw->AddText(heading, title_size, ImVec2(right - extent.x, y), theme::paper, time.c_str());
}
y += title_size + gap;
draw->AddText(heading, size, ImVec2(left, y), theme::paper, question.c_str(), nullptr, inner);
y += question_extent.y + gap;
// The votes cast, yes in green from the left and no in red from the right: a vote is
// decided by those who vote. The mark is in the middle whatever share the vote needs, and
// green reaches it when the vote would pass: up to the yes votes needed it grows over the
// left half, and past them over the right half. Nothing cast yet, the bar is empty.
const unsigned cast = static_cast<unsigned>(vote.yes) + vote.no;
draw->AddRectFilled(ImVec2(left, y), ImVec2(right, y + bar), skate_theme::tile_light);
if (cast) {
float share = static_cast<float>(vote.yes) / static_cast<float>(cast);
if (const unsigned needed = vote.needed) {
const unsigned yes = vote.yes;
share = yes <= needed ? 0.5f * static_cast<float>(yes) / static_cast<float>(needed)
: 0.5f + 0.5f * static_cast<float>(yes - needed) / static_cast<float>(cast - needed);
}
const float split = std::floor(left + inner * std::clamp(share, 0.0f, 1.0f));
if (vote.yes) draw->AddRectFilled(ImVec2(left, y), ImVec2(split, y + bar), green);
if (vote.no) draw->AddRectFilled(ImVec2(split, y), ImVec2(right, y + bar), red);
if (running && vote.needed) {
const float mark = std::floor(left + inner * 0.5f);
draw->AddRectFilled(ImVec2(mark - scale, y - 3.0f * scale), ImVec2(mark + scale, y + bar + 3.0f * scale), theme::paper);
}
}
y += bar + gap;
float x = left;
const auto word = [&](const std::string& text, ImU32 colour) {
draw->AddText(body, fine, ImVec2(x, y), colour, text.c_str());
x += body->CalcTextSizeA(fine, FLT_MAX, 0.0f, text.c_str()).x + 14.0f * scale;
};
word("Yes " + std::to_string(vote.yes), green);
word("No " + std::to_string(vote.no), red);
{
const auto needed = std::to_string(vote.needed) + " needed";
const auto extent = body->CalcTextSizeA(fine, FLT_MAX, 0.0f, needed.c_str());
draw->AddText(body, fine, ImVec2(right - extent.x, y), theme::muted, needed.c_str());
}
y += fine;
if (!running) return;
y += gap;
if (!answers) {
draw->AddText(body, fine, ImVec2(left, y), theme::muted, "This vote is about you, so you have no vote in it.");
return;
}
// Two tiles as the menu's: grey, the answer's colour once it is this player's, each with its
// bind on a white keycap.
const float button_width = (inner - gap) * 0.5f;
const auto button = [&](float at, bool yes, std::uint32_t bind) {
const ImVec2 a(at, y), b(at + button_width, y + button_height);
const ImU32 colour = yes ? green : red;
const bool chosen = vote.mine == (yes ? 1 : 2);
const bool hovered = clickable && ImGui::IsMouseHoveringRect(a, b, false);
draw->AddRectFilled(a, b, chosen ? colour : hovered ? skate_theme::tile_light : skate_theme::tile_grey);
if (!chosen) draw->AddRectFilled(ImVec2(a.x, b.y - 3.0f * scale), b, colour);
const std::string key = bind ? dingosdk::controller_combo_label(bind) : std::string{};
const char* label = yes ? "YES" : "NO";
const float label_size = 15.0f * scale, key_size = 12.0f * scale, cap_height = 20.0f * scale;
const auto label_extent = heading->CalcTextSizeA(label_size, FLT_MAX, 0.0f, label);
const auto key_extent = key.empty() ? ImVec2{} : heading->CalcTextSizeA(key_size, FLT_MAX, 0.0f, key.c_str());
const float cap_width = key.empty() ? 0.0f : key_extent.x + 12.0f * scale, between = key.empty() ? 0.0f : 8.0f * scale;
float cursor = a.x + (button_width - cap_width - between - label_extent.x) * 0.5f;
if (!key.empty()) {
const ImVec2 cap(cursor, a.y + (button_height - cap_height) * 0.5f);
draw->AddRectFilled(cap, ImVec2(cap.x + cap_width, cap.y + cap_height), theme::paper, 3.0f * scale);
draw->AddText(heading, key_size, ImVec2(cap.x + 6.0f * scale, cap.y + (cap_height - key_extent.y) * 0.5f), skate_theme::black,
key.c_str());
cursor += cap_width + between;
}
draw->AddText(heading, label_size, ImVec2(cursor, a.y + (button_height - label_extent.y) * 0.5f),
chosen ? skate_theme::black : theme::paper, label);
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) queue_multiplayer_action("vote", yes ? "yes" : "no");
};
button(left, true, vote.yes_bind);
button(left + button_width + gap, false, vote.no_bind);
}
void draw_chat() {
auto& s = state();
auto& c = chat();
if (!c.feed.available) return;
auto* heading = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
const auto display = ImGui::GetIO().DisplaySize;
const float scale = std::clamp(display.y / 1080.0f, 1.0f, 2.0f);
const float width = 520.0f * scale, margin = 24.0f * scale;
if (s.chat_visible.load()) {
ImGui::SetNextWindowPos(ImVec2(display.x - margin, display.y - margin), ImGuiCond_Always, ImVec2(1.0f, 1.0f));
ImGui::SetNextWindowSize(ImVec2(width, 320.0f * scale), ImGuiCond_Always);
ImGui::PushStyleColor(ImGuiCol_WindowBg, with_alpha(theme::ink, 0.92f));
ImGui::PushStyleColor(ImGuiCol_Border, with_alpha(theme::blue, 0.9f));
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(12, 12, 12, 255));
ImGui::PushStyleColor(ImGuiCol_Text, theme::paper);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12.0f * scale, 10.0f * scale));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0f);
constexpr auto flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar;
if (ImGui::Begin("##reskate_chat", nullptr, flags)) {
ImGui::PushFont(body);
ImGui::SetWindowFontScale(scale * 0.9f);
ImGui::BeginChild("##reskate_chat_lines", ImVec2(0.0f, -ImGui::GetFrameHeightWithSpacing()));
ImGui::PushTextWrapPos(0.0f);
auto& layouts = chat_layouts().open;
for (const auto& line : c.feed.lines) {
auto& layout = line_layout(layouts, line);
const ImVec2 line_start = ImGui::GetCursorScreenPos();
const float line_width = ImGui::GetContentRegionAvail().x;
if (!line.tag.empty()) {
const float size = ImGui::GetFontSize(), height = ImGui::GetTextLineHeight();
const float badge = role_badge_width(heading, size, line.tag);
draw_role_badge(ImGui::GetWindowDrawList(), heading, size, ImGui::GetCursorScreenPos(), height, line.tag,
name_colour(line), 1.0f);
ImGui::Dummy(ImVec2(badge, height));
ImGui::SameLine(0.0f, 5.0f * scale);
}
auto* draw = ImGui::GetWindowDrawList();
const int name_vertices = draw->VtxBuffer.Size;
ImGui::PushStyleColor(ImGuiCol_Text, name_colour(line));
ImGui::TextUnformatted(layout.label.c_str());
ImGui::PopStyleColor();
shade_nametag_gradient(draw, name_vertices, ImGui::GetItemRectMin().x, ImGui::GetItemRectSize().x,
name_colour(line), ImGui::GetTime());
ImGui::SameLine(0.0f, 6.0f * scale);
const auto& drawn = layout_for(layout, line, ImGui::GetFont(), ImGui::GetFontSize(), line_width,
ImGui::GetCursorScreenPos().x - line_start.x);
ImGui::PushStyleColor(ImGuiCol_Text, text_colour(line));
if (drawn.emotes) {
draw_rich(ImGui::GetWindowDrawList(), ImGui::GetFont(), ImGui::GetFontSize(), line_start, drawn.text,
drawn.rich, ImGui::GetColorU32(ImGuiCol_Text), 1.0f);
ImGui::SetCursorScreenPos(line_start);
ImGui::Dummy(ImVec2(line_width, drawn.rich.height));
} else {
ImGui::TextUnformatted(drawn.text.c_str());
}
ImGui::PopStyleColor();
}
if (c.feed.lines.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, theme::muted);
ImGui::TextUnformatted("No messages yet. Everyone in the session sees what you send.");
ImGui::PopStyleColor();
}
ImGui::PopTextWrapPos();
if (c.scroll_to_bottom) {
ImGui::SetScrollHereY(1.0f);
c.scroll_to_bottom = false;
}
ImGui::EndChild();
if (s.chat_focus_requested.exchange(false)) {
c.scroll_to_bottom = true;
// Opened with "/": an empty box starts the command; a draft left in it stays.
if (s.chat_command_requested.exchange(false) && !c.input[0]) {
c.input.fill(0);
c.input[0] = '/';
}
c.cursor_to_end = true;
// The overlay runs no frames while nothing of it is on screen, so after Esc closed
// the chat ImGui can still hold the box as active, and would write its old text back
// over ours: have it take the text from the buffer again.
const auto input_id = ImGui::GetID("##reskate_chat_input");
if (auto* active = ImGui::GetInputTextState(input_id); active && ImGui::GetActiveID() == input_id)
active->ReloadUserBufAndMoveToEnd();
ImGui::SetKeyboardFocusHere();
} else if (c.refocus_input) {
c.refocus_input = false;
c.cursor_to_end = true;
ImGui::SetKeyboardFocusHere();
}
// Typing "/" lists the commands (drawn above the panel below); Tab cycles through them,
// a command's players, maps or times, or the emotes after a ":".
if (c.cycle.active && std::string_view(c.input.data()) != c.cycle.written) c.cycle = {};
if (const auto& found = suggestions(c, suggesting_for(c)); found.version != c.completions_version) {
c.completions = found.completions;
c.completions_version = found.version;
}
ImGui::SetNextItemWidth(-FLT_MIN);
if (ImGui::InputTextWithHint("##reskate_chat_input", "Say something, or / for commands (Enter sends, Esc closes)",
c.input.data(), c.input.size(),
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackCompletion |
ImGuiInputTextFlags_CallbackAlways,
complete_command, &c)) {
const std::string text(c.input.data());
if (text.find_first_not_of(' ') != std::string::npos) queue_multiplayer_action("chat", text);
c.input.fill(0);
s.chat_visible.store(false);
}
ImGui::PopFont();
}
const auto panel_top = ImGui::GetWindowPos().y;
ImGui::End();
ImGui::PopStyleVar(2);
ImGui::PopStyleColor(4);
const float above = panel_top - 6.0f * scale;
draw_command_list(c, heading, body, ImVec2(display.x - margin - width, above), width, scale);
emote_picker(c, heading, ImVec2(display.x - margin - width, above), width, scale);
draw_announcement(c.feed.announcement, heading, body, display, scale);
draw_vote(c.feed.vote, heading, body, display, scale, true); // the cursor is free while typing
return;
}
// Closed: the newest lines still within their hold, stacked up from the
// corner, each fading on its own. Drawn without a window, so no input.
const auto now = Clock::now();
std::vector<std::pair<const MultiplayerChatLine*, float>> shown;
for (auto it = c.feed.lines.rbegin(); it != c.feed.lines.rend() && shown.size() < closed_lines; ++it) {
const auto at = arrived(c, it->sequence);
if (at == Clock::time_point{}) continue;
const auto age = now - at;
if (age >= chat_hold + chat_fade) break;
const float alpha = age <= chat_hold ? 1.0f
: 1.0f - std::chrono::duration<float>(age - chat_hold) / std::chrono::duration<float>(chat_fade);
shown.emplace_back(&*it, alpha);
}
auto* draw = ImGui::GetForegroundDrawList();
const float size = body->FontSize * scale * 0.9f, name_size = heading->FontSize * scale * 0.9f;
const float pad_x = 10.0f * scale, pad_y = 5.0f * scale, gap = 4.0f * scale;
const float text_width = width - pad_x * 2.0f;
float bottom = display.y - margin;
auto& layouts = chat_layouts().closed;
for (const auto& [line, alpha] : shown) {
auto& layout = line_layout(layouts, *line);
const auto& label = layout.label;
const auto name_extent = heading->CalcTextSizeA(name_size, FLT_MAX, 0.0f, label.c_str());
const float badge = line->tag.empty() ? 0.0f : role_badge_width(heading, name_size, line->tag) + 5.0f * scale;
const float indent = badge + name_extent.x + 6.0f * scale;
// The message wraps under the name rather than beside it, so a long
// line stays inside the panel.
const bool beside = indent < text_width * 0.5f;
const float wrap = beside ? text_width - indent : text_width;
const auto& drawn = layout_for(layout, *line, body, size, wrap, 0.0f);
const bool emotes = drawn.emotes;
const auto text_extent = drawn.extent;
const float height = beside ? std::max(name_extent.y, text_extent.y) : name_extent.y + text_extent.y;
const ImVec2 top_left(display.x - margin - width, bottom - height - pad_y * 2.0f);
draw->AddRectFilled(top_left, ImVec2(display.x - margin, bottom), with_alpha(theme::ink, 0.78f * alpha),
4.0f * scale);
const ImVec2 at(top_left.x + pad_x, top_left.y + pad_y);
draw_role_badge(draw, heading, name_size, at, name_extent.y, line->tag, name_colour(*line), alpha);
const int name_vertices = draw->VtxBuffer.Size;
draw->AddText(heading, name_size, ImVec2(at.x + badge, at.y), with_alpha(name_colour(*line), alpha), label.c_str());
shade_nametag_gradient(draw, name_vertices, at.x + badge, name_extent.x, name_colour(*line), ImGui::GetTime());
const ImVec2 text_at = beside ? ImVec2(at.x + indent, at.y) : ImVec2(at.x, at.y + name_extent.y);
const auto text = with_alpha(text_colour(*line), alpha);
if (emotes) draw_rich(draw, body, size, text_at, drawn.text, drawn.rich, text, alpha);
else draw->AddText(body, size, text_at, text, drawn.text.c_str(), nullptr, wrap);
bottom = top_left.y - gap;
}
// Clickable while the menu or the console has freed the cursor.
draw_announcement(c.feed.announcement, heading, body, display, scale);
draw_vote(c.feed.vote, heading, body, display, scale, s.visible.load() || s.console_visible.load());
}
} // namespace dingosdk::overlay::detail