69
src/App.cpp
69
src/App.cpp
@@ -189,23 +189,57 @@ auto App::init_wayland() -> void
|
||||
bool ctrl = app->m_kbd.mod_active("Control");
|
||||
bool alt = app->m_kbd.mod_active("Mod1");
|
||||
bool meta = app->m_kbd.mod_active("Mod4");
|
||||
if (!(ctrl || alt || meta)) {
|
||||
if (sym == XKB_KEY_Left) {
|
||||
app->m_kbd.typing.push_back(1);
|
||||
} else if (sym == XKB_KEY_Down) {
|
||||
app->m_kbd.typing.push_back(2);
|
||||
} else if (sym == XKB_KEY_Up) {
|
||||
app->m_kbd.typing.push_back(3);
|
||||
} else if (sym == XKB_KEY_Right) {
|
||||
app->m_kbd.typing.push_back(4);
|
||||
} else {
|
||||
u32 cp = xkb_keysym_to_utf32(sym);
|
||||
if (cp >= 0x20) {
|
||||
char buf[8];
|
||||
int n = xkb_keysym_to_utf8(sym, buf, sizeof(buf));
|
||||
if (n > 0)
|
||||
app->m_kbd.typing.push_utf8(buf);
|
||||
}
|
||||
|
||||
bool handled = false;
|
||||
switch (sym) {
|
||||
case XKB_KEY_Left:
|
||||
app->m_kbd.typing.push_back(1);
|
||||
handled = true;
|
||||
break;
|
||||
case XKB_KEY_Down:
|
||||
app->m_kbd.typing.push_back(2);
|
||||
handled = true;
|
||||
break;
|
||||
case XKB_KEY_Up:
|
||||
app->m_kbd.typing.push_back(3);
|
||||
handled = true;
|
||||
break;
|
||||
case XKB_KEY_Right:
|
||||
app->m_kbd.typing.push_back(4);
|
||||
handled = true;
|
||||
break;
|
||||
case XKB_KEY_BackSpace:
|
||||
app->m_kbd.typing.push_back(8);
|
||||
handled = true;
|
||||
break;
|
||||
case XKB_KEY_Delete:
|
||||
case XKB_KEY_KP_Delete:
|
||||
app->m_kbd.typing.push_back(0x7F);
|
||||
handled = true;
|
||||
break;
|
||||
case XKB_KEY_Return:
|
||||
case XKB_KEY_KP_Enter:
|
||||
app->m_kbd.typing.push_back('\n');
|
||||
handled = true;
|
||||
break;
|
||||
case XKB_KEY_w:
|
||||
case XKB_KEY_W:
|
||||
if (ctrl) {
|
||||
app->m_kbd.typing.push_back(8);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!handled && !(ctrl || alt || meta)) {
|
||||
u32 cp = xkb_keysym_to_utf32(sym);
|
||||
if (cp >= 0x20) {
|
||||
char buf[8];
|
||||
int n = xkb_keysym_to_utf8(sym, buf, sizeof(buf));
|
||||
if (n > 0)
|
||||
app->m_kbd.typing.push_utf8(buf);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -346,6 +380,7 @@ auto App::init_egl() -> void
|
||||
auto const font_handle = m_tr->load_font(*font, std::span(fallback_paths));
|
||||
assert(font_handle && "Could not load font");
|
||||
m_font = *font_handle;
|
||||
m_gui->set_font(m_font);
|
||||
}
|
||||
|
||||
auto App::init_signal() -> void
|
||||
|
||||
407
src/ImGui.cpp
407
src/ImGui.cpp
@@ -1,9 +1,124 @@
|
||||
#include "ImGui.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include <raylib.h>
|
||||
|
||||
namespace {
|
||||
|
||||
struct CodepointSpan {
|
||||
u32 codepoint {};
|
||||
std::size_t start {};
|
||||
std::size_t end {};
|
||||
};
|
||||
|
||||
auto decode_utf8(std::string_view text) -> std::vector<CodepointSpan>
|
||||
{
|
||||
std::vector<CodepointSpan> spans;
|
||||
std::size_t i = 0;
|
||||
spans.reserve(text.size());
|
||||
|
||||
while (i < text.size()) {
|
||||
u8 const byte = static_cast<u8>(text[i]);
|
||||
std::size_t const start = i;
|
||||
std::size_t length = 1;
|
||||
u32 cp = 0xFFFD;
|
||||
|
||||
if (byte < 0x80) {
|
||||
cp = byte;
|
||||
} else if ((byte & 0xE0) == 0xC0) {
|
||||
if (i + 1 < text.size()) {
|
||||
u8 const b1 = static_cast<u8>(text[i + 1]);
|
||||
if ((b1 & 0xC0) == 0x80) {
|
||||
u32 const t = ((static_cast<u32>(byte) & 0x1F) << 6)
|
||||
| (static_cast<u32>(b1) & 0x3F);
|
||||
if (t >= 0x80) {
|
||||
cp = t;
|
||||
length = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ((byte & 0xF0) == 0xE0) {
|
||||
if (i + 2 < text.size()) {
|
||||
u8 const b1 = static_cast<u8>(text[i + 1]);
|
||||
u8 const b2 = static_cast<u8>(text[i + 2]);
|
||||
if ((b1 & 0xC0) == 0x80 && (b2 & 0xC0) == 0x80) {
|
||||
u32 const t = ((static_cast<u32>(byte) & 0x0F) << 12)
|
||||
| ((static_cast<u32>(b1) & 0x3F) << 6)
|
||||
| (static_cast<u32>(b2) & 0x3F);
|
||||
if (t >= 0x800 && (t < 0xD800 || t > 0xDFFF)) {
|
||||
cp = t;
|
||||
length = 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ((byte & 0xF8) == 0xF0) {
|
||||
if (i + 3 < text.size()) {
|
||||
u8 const b1 = static_cast<u8>(text[i + 1]);
|
||||
u8 const b2 = static_cast<u8>(text[i + 2]);
|
||||
u8 const b3 = static_cast<u8>(text[i + 3]);
|
||||
if ((b1 & 0xC0) == 0x80 && (b2 & 0xC0) == 0x80
|
||||
&& (b3 & 0xC0) == 0x80) {
|
||||
u32 const t = ((static_cast<u32>(byte) & 0x07) << 18)
|
||||
| ((static_cast<u32>(b1) & 0x3F) << 12)
|
||||
| ((static_cast<u32>(b2) & 0x3F) << 6)
|
||||
| (static_cast<u32>(b3) & 0x3F);
|
||||
if (t >= 0x10000 && t <= 0x10FFFF) {
|
||||
cp = t;
|
||||
length = 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spans.push_back(CodepointSpan { cp, start, start + length });
|
||||
i += length;
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
auto encode_utf8(u32 cp) -> std::string
|
||||
{
|
||||
char buf[5] = { 0, 0, 0, 0, 0 };
|
||||
int len = 0;
|
||||
if (cp <= 0x7F) {
|
||||
buf[len++] = static_cast<char>(cp);
|
||||
} else if (cp <= 0x7FF) {
|
||||
buf[len++] = static_cast<char>(0xC0 | (cp >> 6));
|
||||
buf[len++] = static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp <= 0xFFFF) {
|
||||
if (cp >= 0xD800 && cp <= 0xDFFF)
|
||||
return {};
|
||||
buf[len++] = static_cast<char>(0xE0 | (cp >> 12));
|
||||
buf[len++] = static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
buf[len++] = static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp <= 0x10FFFF) {
|
||||
buf[len++] = static_cast<char>(0xF0 | (cp >> 18));
|
||||
buf[len++] = static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||
buf[len++] = static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
buf[len++] = static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
return std::string(buf, len);
|
||||
}
|
||||
|
||||
constexpr float HORIZONTAL_PADDING = 6.0f;
|
||||
constexpr float VERTICAL_PADDING = 4.0f;
|
||||
constexpr float CARET_WIDTH = 2.0f;
|
||||
constexpr float CARET_INSET = 1.0f;
|
||||
constexpr double CARET_BLINK_INTERVAL = 0.5;
|
||||
|
||||
} // namespace
|
||||
|
||||
ImGui::ImGui(std::shared_ptr<TextRenderer> text_renderer)
|
||||
: m_text_renderer(text_renderer)
|
||||
{
|
||||
@@ -18,20 +133,25 @@ void ImGui::begin(u32 const rune, bool ctrl, bool shift)
|
||||
|
||||
void ImGui::end() { }
|
||||
|
||||
void ImGui::set_font(FontHandle font) { m_font = font; }
|
||||
|
||||
auto ImGui::text_input(std::size_t id, std::pmr::string &str, Rectangle rec,
|
||||
TextInputOptions options) -> std::bitset<2>
|
||||
{
|
||||
assert(id != 0);
|
||||
assert(
|
||||
m_font.has_value() && "ImGui font must be set before using text input");
|
||||
|
||||
bool submitted { false };
|
||||
bool changed { false };
|
||||
|
||||
if (!m_ti_states.contains(id)) {
|
||||
m_ti_states[id] = {};
|
||||
}
|
||||
auto &state = m_ti_states[id];
|
||||
|
||||
assert(!options.multiline && "Multiline not yet implemented.");
|
||||
|
||||
if (m_focused_id == 0)
|
||||
m_focused_id = id;
|
||||
|
||||
if (options.font_size > rec.height) {
|
||||
TraceLog(LOG_WARNING,
|
||||
std::format("Text size for text input {} is bigger than height ({} "
|
||||
@@ -40,8 +160,289 @@ auto ImGui::text_input(std::size_t id, std::pmr::string &str, Rectangle rec,
|
||||
.c_str());
|
||||
}
|
||||
|
||||
std::string_view str_view(str.data(), str.size());
|
||||
auto spans = decode_utf8(str_view);
|
||||
|
||||
auto is_space = [](u32 cp) -> bool {
|
||||
if (cp == '\n' || cp == '\r' || cp == '\t' || cp == '\v' || cp == '\f')
|
||||
return true;
|
||||
if (cp <= 0x7F)
|
||||
return std::isspace(static_cast<unsigned char>(cp)) != 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
auto clamp_cursor = [&]() -> std::size_t {
|
||||
int const max_idx = static_cast<int>(spans.size());
|
||||
state.current_rune_idx = std::clamp(state.current_rune_idx, 0, max_idx);
|
||||
if (state.current_rune_idx == max_idx)
|
||||
return str.size();
|
||||
return spans[state.current_rune_idx].start;
|
||||
};
|
||||
|
||||
std::size_t caret_byte = clamp_cursor();
|
||||
|
||||
auto refresh_spans = [&]() {
|
||||
str_view = std::string_view(str.data(), str.size());
|
||||
spans = decode_utf8(str_view);
|
||||
caret_byte = clamp_cursor();
|
||||
};
|
||||
|
||||
auto erase_range = [&](std::size_t byte_begin, std::size_t byte_end) {
|
||||
if (byte_end > byte_begin && byte_begin < str.size()) {
|
||||
str.erase(byte_begin, byte_end - byte_begin);
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
|
||||
bool caret_activity = false;
|
||||
|
||||
if (m_focused_id == id && m_rune != 0) {
|
||||
bool request_refresh = false;
|
||||
auto handle_backspace = [&]() {
|
||||
if (state.current_rune_idx <= 0
|
||||
|| state.current_rune_idx > static_cast<int>(spans.size()))
|
||||
return;
|
||||
|
||||
if (m_ctrl) {
|
||||
int idx = state.current_rune_idx;
|
||||
int scan = idx - 1;
|
||||
while (scan >= 0
|
||||
&& is_space(
|
||||
spans[static_cast<std::size_t>(scan)].codepoint))
|
||||
scan--;
|
||||
while (scan >= 0
|
||||
&& !is_space(
|
||||
spans[static_cast<std::size_t>(scan)].codepoint))
|
||||
scan--;
|
||||
int start_idx = std::max(scan + 1, 0);
|
||||
std::size_t byte_begin
|
||||
= spans[static_cast<std::size_t>(start_idx)].start;
|
||||
std::size_t byte_end = (idx >= static_cast<int>(spans.size()))
|
||||
? str.size()
|
||||
: spans[static_cast<std::size_t>(idx)].start;
|
||||
erase_range(byte_begin, byte_end);
|
||||
state.current_rune_idx = start_idx;
|
||||
} else {
|
||||
auto const &prev = spans[static_cast<std::size_t>(
|
||||
state.current_rune_idx - 1)];
|
||||
erase_range(prev.start, prev.end);
|
||||
state.current_rune_idx--;
|
||||
}
|
||||
request_refresh = true;
|
||||
};
|
||||
|
||||
auto handle_delete = [&]() {
|
||||
if (state.current_rune_idx < 0
|
||||
|| state.current_rune_idx >= static_cast<int>(spans.size())) {
|
||||
if (!m_ctrl)
|
||||
return;
|
||||
}
|
||||
|
||||
int idx = state.current_rune_idx;
|
||||
if (m_ctrl) {
|
||||
int scan = idx;
|
||||
while (scan < static_cast<int>(spans.size())
|
||||
&& is_space(
|
||||
spans[static_cast<std::size_t>(scan)].codepoint))
|
||||
scan++;
|
||||
while (scan < static_cast<int>(spans.size())
|
||||
&& !is_space(
|
||||
spans[static_cast<std::size_t>(scan)].codepoint))
|
||||
scan++;
|
||||
std::size_t byte_begin = (idx < static_cast<int>(spans.size()))
|
||||
? spans[static_cast<std::size_t>(idx)].start
|
||||
: str.size();
|
||||
std::size_t byte_end = (scan < static_cast<int>(spans.size()))
|
||||
? spans[static_cast<std::size_t>(scan)].start
|
||||
: str.size();
|
||||
erase_range(byte_begin, byte_end);
|
||||
} else if (idx < static_cast<int>(spans.size())) {
|
||||
auto const &curr = spans[static_cast<std::size_t>(idx)];
|
||||
erase_range(curr.start, curr.end);
|
||||
}
|
||||
request_refresh = true;
|
||||
};
|
||||
|
||||
switch (m_rune) {
|
||||
case 1: // Left (H)
|
||||
if (state.current_rune_idx > 0) {
|
||||
state.current_rune_idx--;
|
||||
if (m_ctrl) {
|
||||
while (state.current_rune_idx > 0
|
||||
&& is_space(spans[static_cast<std::size_t>(
|
||||
state.current_rune_idx)]
|
||||
.codepoint))
|
||||
state.current_rune_idx--;
|
||||
while (state.current_rune_idx > 0
|
||||
&& !is_space(spans[static_cast<std::size_t>(
|
||||
state.current_rune_idx)]
|
||||
.codepoint))
|
||||
state.current_rune_idx--;
|
||||
}
|
||||
caret_byte = clamp_cursor();
|
||||
}
|
||||
break;
|
||||
case 4: // Right (L)
|
||||
if (state.current_rune_idx < static_cast<int>(spans.size())) {
|
||||
state.current_rune_idx++;
|
||||
if (m_ctrl) {
|
||||
while (
|
||||
state.current_rune_idx < static_cast<int>(spans.size())
|
||||
&& is_space(spans[static_cast<std::size_t>(
|
||||
state.current_rune_idx - 1)]
|
||||
.codepoint))
|
||||
state.current_rune_idx++;
|
||||
while (
|
||||
state.current_rune_idx < static_cast<int>(spans.size())
|
||||
&& !is_space(spans[static_cast<std::size_t>(
|
||||
state.current_rune_idx - 1)]
|
||||
.codepoint))
|
||||
state.current_rune_idx++;
|
||||
}
|
||||
caret_byte = clamp_cursor();
|
||||
}
|
||||
break;
|
||||
case 3: // Up (K)
|
||||
state.current_rune_idx = 0;
|
||||
caret_byte = clamp_cursor();
|
||||
break;
|
||||
case 2: // Down (J)
|
||||
state.current_rune_idx = static_cast<int>(spans.size());
|
||||
caret_byte = clamp_cursor();
|
||||
break;
|
||||
case 8: // Backspace
|
||||
handle_backspace();
|
||||
break;
|
||||
case 0x7F: // Delete
|
||||
handle_delete();
|
||||
break;
|
||||
case '\r':
|
||||
case '\n':
|
||||
if (options.multiline) {
|
||||
auto encoded = encode_utf8('\n');
|
||||
if (!encoded.empty()) {
|
||||
str.insert(caret_byte, encoded);
|
||||
state.current_rune_idx++;
|
||||
changed = true;
|
||||
request_refresh = true;
|
||||
}
|
||||
} else {
|
||||
submitted = true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (m_rune >= 0x20) {
|
||||
auto encoded = encode_utf8(m_rune);
|
||||
if (!encoded.empty()) {
|
||||
str.insert(caret_byte, encoded);
|
||||
state.current_rune_idx++;
|
||||
changed = true;
|
||||
request_refresh = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (request_refresh) {
|
||||
refresh_spans();
|
||||
} else {
|
||||
caret_byte = clamp_cursor();
|
||||
}
|
||||
caret_activity = true;
|
||||
}
|
||||
|
||||
double const dt = static_cast<double>(GetFrameTime());
|
||||
if (m_focused_id == id) {
|
||||
if (caret_activity) {
|
||||
state.caret_timer = 0.0;
|
||||
state.caret_visible = true;
|
||||
} else {
|
||||
if (state.caret_timer == 0.0)
|
||||
state.caret_visible = true;
|
||||
state.caret_timer += dt;
|
||||
if (state.caret_timer >= CARET_BLINK_INTERVAL) {
|
||||
int toggles = static_cast<int>(state.caret_timer / CARET_BLINK_INTERVAL);
|
||||
state.caret_timer
|
||||
= std::fmod(state.caret_timer, CARET_BLINK_INTERVAL);
|
||||
if (toggles % 2 == 1)
|
||||
state.caret_visible = !state.caret_visible;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.caret_visible = false;
|
||||
state.caret_timer = 0.0;
|
||||
}
|
||||
|
||||
Vector2 prefix_metrics { 0.0f, 0.0f };
|
||||
Vector2 full_metrics { 0.0f, 0.0f };
|
||||
if (m_font.has_value() && m_text_renderer) {
|
||||
std::string_view const prefix_view(str.data(), caret_byte);
|
||||
prefix_metrics = m_text_renderer->measure_text(
|
||||
*m_font, prefix_view, static_cast<int>(options.font_size));
|
||||
full_metrics = m_text_renderer->measure_text(
|
||||
*m_font, str_view, static_cast<int>(options.font_size));
|
||||
}
|
||||
|
||||
state.cursor_position.x = prefix_metrics.x;
|
||||
|
||||
float const available_width
|
||||
= std::max(0.0f, rec.width - 2.0f * HORIZONTAL_PADDING);
|
||||
if (full_metrics.x <= available_width) {
|
||||
state.scroll_offset.x = 0.0f;
|
||||
} else {
|
||||
float &scroll = state.scroll_offset.x;
|
||||
float caret_local = state.cursor_position.x - scroll;
|
||||
if (caret_local > available_width) {
|
||||
scroll = state.cursor_position.x - available_width;
|
||||
} else if (caret_local < 0.0f) {
|
||||
scroll = state.cursor_position.x;
|
||||
}
|
||||
scroll = std::clamp(
|
||||
scroll, 0.0f, std::max(0.0f, full_metrics.x - available_width));
|
||||
}
|
||||
state.scroll_offset.y = 0.0f;
|
||||
|
||||
Color const bg_col { 16, 16, 16, 100 };
|
||||
Color const border_col { 220, 220, 220, 180 };
|
||||
DrawRectangleRec(rec, bg_col);
|
||||
DrawRectangleLinesEx(rec, 1.0f, border_col);
|
||||
|
||||
float const text_top = rec.y + VERTICAL_PADDING;
|
||||
float const baseline_y = text_top + options.font_size;
|
||||
float caret_height = std::min(options.font_size,
|
||||
std::max(0.0f, rec.height - 2.0f * VERTICAL_PADDING));
|
||||
if (caret_height <= 0.0f)
|
||||
caret_height = options.font_size;
|
||||
float caret_top = baseline_y - caret_height;
|
||||
caret_top = std::clamp(caret_top, rec.y + VERTICAL_PADDING,
|
||||
rec.y + rec.height - VERTICAL_PADDING - caret_height);
|
||||
state.cursor_position.y = caret_top;
|
||||
|
||||
BeginScissorMode(rec.x, rec.y, rec.width, rec.height);
|
||||
{
|
||||
if (m_font.has_value() && m_text_renderer) {
|
||||
Vector2 const text_pos {
|
||||
rec.x + HORIZONTAL_PADDING - state.scroll_offset.x,
|
||||
baseline_y,
|
||||
};
|
||||
Color const text_color { 255, 255, 255, 255 };
|
||||
m_text_renderer->draw_text(*m_font, str_view, text_pos,
|
||||
static_cast<int>(options.font_size), text_color);
|
||||
|
||||
if (m_focused_id == id && state.caret_visible) {
|
||||
float const caret_x = std::round(rec.x + HORIZONTAL_PADDING
|
||||
+ state.cursor_position.x - state.scroll_offset.x
|
||||
+ CARET_INSET);
|
||||
Rectangle caret_rect {
|
||||
caret_x,
|
||||
caret_top,
|
||||
CARET_WIDTH,
|
||||
caret_height,
|
||||
};
|
||||
Color const caret_color { 255, 255, 255, 200 };
|
||||
DrawRectangleRec(caret_rect, caret_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
EndScissorMode();
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
#include <bitset>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
#include <raylib.h>
|
||||
|
||||
#include "TextRenderer.hpp"
|
||||
|
||||
constexpr float DEFAULT_FONT_SIZE { 16 };
|
||||
constexpr float DEFAULT_FONT_SIZE { 24 };
|
||||
|
||||
struct TextInputOptions {
|
||||
bool multiline { false };
|
||||
@@ -30,6 +31,8 @@ struct ImGui {
|
||||
auto text_input(std::size_t id, std::pmr::string &str, Rectangle rec,
|
||||
TextInputOptions options = {}) -> std::bitset<2>;
|
||||
|
||||
void set_font(FontHandle font);
|
||||
|
||||
[[nodiscard]] inline auto id(std::string_view const str) -> std::size_t
|
||||
{
|
||||
std::hash<std::string_view> hasher;
|
||||
@@ -41,6 +44,8 @@ private:
|
||||
int current_rune_idx { 0 };
|
||||
Vector2 scroll_offset; // y not used if multiline == false
|
||||
Vector2 cursor_position; // y not used if multiline == false
|
||||
bool caret_visible { true };
|
||||
double caret_timer { 0.0 };
|
||||
};
|
||||
|
||||
std::unordered_map<std::size_t, TextInputState> m_ti_states;
|
||||
@@ -49,6 +54,7 @@ private:
|
||||
bool m_ctrl {};
|
||||
bool m_shift {};
|
||||
|
||||
std::optional<FontHandle> m_font {};
|
||||
std::shared_ptr<TextRenderer> m_text_renderer {};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user