...
This commit is contained in:
532
ui/glyph_cache.sx
Normal file
532
ui/glyph_cache.sx
Normal file
@@ -0,0 +1,532 @@
|
||||
#import "modules/std.sx";
|
||||
#import "modules/opengl.sx";
|
||||
#import "ui/types.sx";
|
||||
|
||||
// Cached glyph data with UV coordinates into the atlas texture
|
||||
CachedGlyph :: struct {
|
||||
uv_x: f32;
|
||||
uv_y: f32;
|
||||
uv_w: f32;
|
||||
uv_h: f32;
|
||||
width: f32;
|
||||
height: f32;
|
||||
offset_x: f32;
|
||||
offset_y: f32;
|
||||
advance: f32;
|
||||
}
|
||||
|
||||
// Cache entry: key + glyph data
|
||||
GlyphEntry :: struct {
|
||||
key: u32;
|
||||
glyph: CachedGlyph;
|
||||
}
|
||||
|
||||
// Quantize font size to half-point increments to limit cache entries.
|
||||
// e.g., 13.0 -> 26, 13.5 -> 27, 14.0 -> 28
|
||||
quantize_size :: (font_size: f32) -> u16 {
|
||||
xx (font_size * 2.0 + 0.5);
|
||||
}
|
||||
|
||||
dequantize_size :: (q: u16) -> f32 {
|
||||
xx q / 2.0;
|
||||
}
|
||||
|
||||
// Pack (glyph_index, size_quantized) into a single u32 for fast comparison
|
||||
make_glyph_key :: (glyph_index: u16, size_quantized: u16) -> u32 {
|
||||
(xx glyph_index << 16) | xx size_quantized;
|
||||
}
|
||||
|
||||
// Shaped glyph — output of text shaping (positioned glyph with index)
|
||||
ShapedGlyph :: struct {
|
||||
glyph_index: u16;
|
||||
x: f32; // horizontal position (logical units, cumulative)
|
||||
y: f32; // vertical offset (logical units)
|
||||
advance: f32; // advance width (logical units)
|
||||
}
|
||||
|
||||
is_ascii :: (text: string) -> bool {
|
||||
i : s64 = 0;
|
||||
while i < text.len {
|
||||
if text[i] >= 128 { return false; }
|
||||
i += 1;
|
||||
}
|
||||
true;
|
||||
}
|
||||
|
||||
// kbts constants (C enum values)
|
||||
KBTS_DIRECTION_DONT_KNOW :u32: 0;
|
||||
KBTS_LANGUAGE_DONT_KNOW :u32: 0;
|
||||
KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX :u32: 0;
|
||||
|
||||
// SX structs matching kbts C struct layouts (64-bit).
|
||||
// We define these in SX to access fields directly, casting from opaque C pointers.
|
||||
|
||||
KbtsGlyphIterator :: struct {
|
||||
glyph_storage: *void;
|
||||
current_glyph: *void;
|
||||
last_advance_x: s32;
|
||||
x: s32;
|
||||
y: s32;
|
||||
}
|
||||
|
||||
KbtsRun :: struct {
|
||||
font: *void;
|
||||
script: u32;
|
||||
paragraph_direction: u32;
|
||||
direction: u32;
|
||||
flags: u32;
|
||||
glyphs: KbtsGlyphIterator;
|
||||
}
|
||||
|
||||
KbtsGlyph :: struct {
|
||||
prev: *void;
|
||||
next: *void;
|
||||
codepoint: u32;
|
||||
id: u16;
|
||||
uid: u16;
|
||||
user_id_or_codepoint_index: s32;
|
||||
offset_x: s32;
|
||||
offset_y: s32;
|
||||
advance_x: s32;
|
||||
advance_y: s32;
|
||||
}
|
||||
|
||||
// kbts_font_info2 base (simplified — we only need the Size field for dispatch)
|
||||
KBTS_FONT_INFO_STRING_ID_COUNT :s32: 7;
|
||||
|
||||
KbtsFontInfo2 :: struct {
|
||||
size: u32;
|
||||
strings: [7]*void; // char* array
|
||||
string_lengths: [7]u16;
|
||||
style_flags: u32;
|
||||
weight: u32;
|
||||
width: u32;
|
||||
}
|
||||
|
||||
KbtsFontInfo2_1 :: struct {
|
||||
base: KbtsFontInfo2;
|
||||
units_per_em: u16;
|
||||
x_min: s16;
|
||||
y_min: s16;
|
||||
x_max: s16;
|
||||
y_max: s16;
|
||||
ascent: s16;
|
||||
descent: s16;
|
||||
line_gap: s16;
|
||||
}
|
||||
|
||||
GLYPH_ATLAS_W :s32: 1024;
|
||||
GLYPH_ATLAS_H :s32: 1024;
|
||||
FONTINFO_SIZE :s64: 256;
|
||||
|
||||
PackResult :: struct {
|
||||
x, y: s32;
|
||||
}
|
||||
|
||||
// Dynamic glyph cache with on-demand rasterization and texture atlas packing.
|
||||
GlyphCache :: struct {
|
||||
// Font data
|
||||
font_info: *void; // heap-allocated stbtt_fontinfo (256 bytes)
|
||||
font_data: *void; // raw TTF file bytes (kept alive for stbtt)
|
||||
|
||||
// Atlas texture (GPU)
|
||||
texture_id: u32;
|
||||
atlas_width: s32;
|
||||
atlas_height: s32;
|
||||
|
||||
// Atlas bitmap (CPU-side for updates)
|
||||
bitmap: [*]u8;
|
||||
|
||||
// Shelf packer state
|
||||
shelf_y: s32;
|
||||
shelf_height: s32;
|
||||
cursor_x: s32;
|
||||
padding: s32;
|
||||
|
||||
// Glyph lookup cache (flat list, linear scan)
|
||||
entries: List(GlyphEntry);
|
||||
|
||||
// Dirty tracking for texture upload
|
||||
dirty: bool;
|
||||
|
||||
// Font vertical metrics (at reference size 1.0 — scale by font_size)
|
||||
ascent: f32;
|
||||
descent: f32;
|
||||
line_gap: f32;
|
||||
|
||||
// HiDPI: physical pixels per logical pixel (e.g. 2.0 on Retina)
|
||||
dpi_scale: f32;
|
||||
inv_dpi: f32;
|
||||
|
||||
// Text shaping (kb_text_shape)
|
||||
shape_ctx: *void;
|
||||
shape_font: *void;
|
||||
units_per_em: u16;
|
||||
font_data_size: s32;
|
||||
shaped_buf: List(ShapedGlyph);
|
||||
|
||||
init :: (self: *GlyphCache, path: [:0]u8, default_size: f32) {
|
||||
// Zero out the entire struct first (parent may be uninitialized with = ---)
|
||||
memset(self, 0, size_of(GlyphCache));
|
||||
|
||||
// Load font file
|
||||
file_size : s32 = 0;
|
||||
font_data := read_file_bytes(path, @file_size);
|
||||
if xx font_data == 0 {
|
||||
out("Failed to load font: ");
|
||||
out(path);
|
||||
out("\n");
|
||||
return;
|
||||
}
|
||||
self.font_data = xx font_data;
|
||||
self.font_data_size = file_size;
|
||||
|
||||
// Init stbtt_fontinfo
|
||||
self.font_info = context.allocator.alloc(FONTINFO_SIZE);
|
||||
memset(self.font_info, 0, FONTINFO_SIZE);
|
||||
stbtt_InitFont(self.font_info, font_data, 0);
|
||||
|
||||
// Get font vertical metrics (in unscaled font units)
|
||||
ascent_i : s32 = 0;
|
||||
descent_i : s32 = 0;
|
||||
linegap_i : s32 = 0;
|
||||
stbtt_GetFontVMetrics(self.font_info, @ascent_i, @descent_i, @linegap_i);
|
||||
|
||||
// Store unscaled metrics — we'll scale per font_size in measure_text
|
||||
self.ascent = xx ascent_i;
|
||||
self.descent = xx descent_i;
|
||||
self.line_gap = xx linegap_i;
|
||||
|
||||
// Init text shaping context
|
||||
self.shape_ctx = xx kbts_CreateShapeContext(xx 0, xx 0);
|
||||
if xx self.shape_ctx != 0 {
|
||||
self.shape_font = xx kbts_ShapePushFontFromMemory(xx self.shape_ctx, self.font_data, file_size, 0);
|
||||
// Get font metrics (units_per_em) from kbts
|
||||
kb_info : KbtsFontInfo2_1 = ---;
|
||||
memset(@kb_info, 0, size_of(KbtsFontInfo2_1));
|
||||
kb_info.base.size = xx size_of(KbtsFontInfo2_1);
|
||||
kbts_GetFontInfo2(xx self.shape_font, xx @kb_info);
|
||||
self.units_per_em = kb_info.units_per_em;
|
||||
}
|
||||
|
||||
// Allocate atlas bitmap
|
||||
self.atlas_width = GLYPH_ATLAS_W;
|
||||
self.atlas_height = GLYPH_ATLAS_H;
|
||||
bitmap_size : s64 = xx self.atlas_width * xx self.atlas_height;
|
||||
self.bitmap = xx context.allocator.alloc(bitmap_size);
|
||||
memset(self.bitmap, 0, bitmap_size);
|
||||
|
||||
// Shelf packer init
|
||||
self.shelf_y = 0;
|
||||
self.shelf_height = 0;
|
||||
self.cursor_x = 0;
|
||||
self.padding = 1;
|
||||
|
||||
self.dirty = false;
|
||||
self.dpi_scale = 1.0;
|
||||
self.inv_dpi = 1.0;
|
||||
|
||||
// Create OpenGL texture
|
||||
glGenTextures(1, @self.texture_id);
|
||||
glBindTexture(GL_TEXTURE_2D, self.texture_id);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, self.atlas_width, self.atlas_height, 0, GL_RED, GL_UNSIGNED_BYTE, self.bitmap);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
|
||||
|
||||
out("GlyphCache initialized: ");
|
||||
out(path);
|
||||
out("\n");
|
||||
}
|
||||
|
||||
// Look up or rasterize a glyph, returning a pointer to its cached entry.
|
||||
// Returns null for glyphs with no outline AND zero advance (shouldn't happen for valid chars).
|
||||
get_or_rasterize :: (self: *GlyphCache, glyph_index: u16, font_size: f32) -> *CachedGlyph {
|
||||
size_q := quantize_size(font_size);
|
||||
key := make_glyph_key(glyph_index, size_q);
|
||||
|
||||
// Cache lookup (linear scan)
|
||||
i : s64 = 0;
|
||||
while i < self.entries.len {
|
||||
if self.entries.items[i].key == key {
|
||||
return @self.entries.items[i].glyph;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Cache miss — rasterize
|
||||
actual_size := dequantize_size(size_q);
|
||||
scale := stbtt_ScaleForPixelHeight(self.font_info, actual_size);
|
||||
|
||||
// Get glyph bounding box
|
||||
x0 : s32 = 0;
|
||||
y0 : s32 = 0;
|
||||
x1 : s32 = 0;
|
||||
y1 : s32 = 0;
|
||||
stbtt_GetGlyphBitmapBox(self.font_info, xx glyph_index, scale, scale, @x0, @y0, @x1, @y1);
|
||||
|
||||
glyph_w := if x1 > x0 then x1 - x0 else 0;
|
||||
glyph_h := if y1 > y0 then y1 - y0 else 0;
|
||||
|
||||
// Get horizontal metrics
|
||||
advance_i : s32 = 0;
|
||||
lsb_i : s32 = 0;
|
||||
stbtt_GetGlyphHMetrics(self.font_info, xx glyph_index, @advance_i, @lsb_i);
|
||||
advance : f32 = xx advance_i * scale;
|
||||
|
||||
// Zero-size glyph (e.g. space) — cache with advance only
|
||||
if glyph_w == 0 or glyph_h == 0 {
|
||||
entry := GlyphEntry.{
|
||||
key = key,
|
||||
glyph = CachedGlyph.{
|
||||
uv_x = 0.0, uv_y = 0.0, uv_w = 0.0, uv_h = 0.0,
|
||||
width = 0.0, height = 0.0,
|
||||
offset_x = xx x0, offset_y = xx y0,
|
||||
advance = advance
|
||||
}
|
||||
};
|
||||
self.entries.append(entry);
|
||||
return @self.entries.items[self.entries.len - 1].glyph;
|
||||
}
|
||||
|
||||
// Pack into atlas
|
||||
pack := self.try_pack(glyph_w, glyph_h);
|
||||
if pack.x < 0 {
|
||||
// Atlas full — grow and retry
|
||||
self.grow();
|
||||
return self.get_or_rasterize(glyph_index, font_size);
|
||||
}
|
||||
|
||||
// Rasterize directly into atlas bitmap
|
||||
dest_offset : s64 = xx pack.y * xx self.atlas_width + xx pack.x;
|
||||
stbtt_MakeGlyphBitmap(
|
||||
self.font_info,
|
||||
@self.bitmap[dest_offset],
|
||||
glyph_w, glyph_h,
|
||||
self.atlas_width,
|
||||
scale, scale,
|
||||
xx glyph_index
|
||||
);
|
||||
self.dirty = true;
|
||||
|
||||
// Compute normalized UV coordinates
|
||||
atlas_wf : f32 = xx self.atlas_width;
|
||||
atlas_hf : f32 = xx self.atlas_height;
|
||||
|
||||
entry := GlyphEntry.{
|
||||
key = key,
|
||||
glyph = CachedGlyph.{
|
||||
uv_x = xx pack.x / atlas_wf,
|
||||
uv_y = xx pack.y / atlas_hf,
|
||||
uv_w = xx glyph_w / atlas_wf,
|
||||
uv_h = xx glyph_h / atlas_hf,
|
||||
width = xx glyph_w,
|
||||
height = xx glyph_h,
|
||||
offset_x = xx x0,
|
||||
offset_y = xx y0,
|
||||
advance = advance
|
||||
}
|
||||
};
|
||||
self.entries.append(entry);
|
||||
return @self.entries.items[self.entries.len - 1].glyph;
|
||||
}
|
||||
|
||||
// Upload dirty atlas to GPU
|
||||
flush :: (self: *GlyphCache) {
|
||||
if self.dirty == false { return; }
|
||||
glBindTexture(GL_TEXTURE_2D, self.texture_id);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, self.atlas_width, self.atlas_height, GL_RED, GL_UNSIGNED_BYTE, self.bitmap);
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
// Shelf-based rectangle packer.
|
||||
// Returns PackResult with x >= 0 on success, x = -1 if no space.
|
||||
try_pack :: (self: *GlyphCache, w: s32, h: s32) -> PackResult {
|
||||
padded_w := w + self.padding;
|
||||
padded_h := h + self.padding;
|
||||
|
||||
// Try fitting on the current shelf
|
||||
eff_h := if self.shelf_height > padded_h then self.shelf_height else padded_h;
|
||||
if self.cursor_x + padded_w <= self.atlas_width and self.shelf_y + eff_h <= self.atlas_height {
|
||||
result := PackResult.{ x = self.cursor_x, y = self.shelf_y };
|
||||
self.cursor_x += padded_w;
|
||||
if padded_h > self.shelf_height {
|
||||
self.shelf_height = padded_h;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Start a new shelf
|
||||
new_shelf_y := self.shelf_y + self.shelf_height;
|
||||
if new_shelf_y + padded_h <= self.atlas_height and padded_w <= self.atlas_width {
|
||||
self.shelf_y = new_shelf_y;
|
||||
self.shelf_height = padded_h;
|
||||
self.cursor_x = padded_w;
|
||||
return PackResult.{ x = 0, y = new_shelf_y };
|
||||
}
|
||||
|
||||
// No space
|
||||
PackResult.{ x = 0 - 1, y = 0 - 1 };
|
||||
}
|
||||
|
||||
// Grow the atlas by doubling dimensions
|
||||
grow :: (self: *GlyphCache) {
|
||||
new_w := self.atlas_width * 2;
|
||||
new_h := self.atlas_height * 2;
|
||||
new_size : s64 = xx new_w * xx new_h;
|
||||
new_bitmap : [*]u8 = xx context.allocator.alloc(new_size);
|
||||
memset(new_bitmap, 0, new_size);
|
||||
|
||||
// Copy old rows into new bitmap
|
||||
y : s32 = 0;
|
||||
while y < self.atlas_height {
|
||||
old_off : s64 = xx y * xx self.atlas_width;
|
||||
new_off : s64 = xx y * xx new_w;
|
||||
memcpy(@new_bitmap[new_off], @self.bitmap[old_off], xx self.atlas_width);
|
||||
y += 1;
|
||||
}
|
||||
|
||||
context.allocator.dealloc(self.bitmap);
|
||||
self.bitmap = new_bitmap;
|
||||
self.atlas_width = new_w;
|
||||
self.atlas_height = new_h;
|
||||
|
||||
// Recreate GL texture
|
||||
glDeleteTextures(1, @self.texture_id);
|
||||
glGenTextures(1, @self.texture_id);
|
||||
glBindTexture(GL_TEXTURE_2D, self.texture_id);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, new_w, new_h, 0, GL_RED, GL_UNSIGNED_BYTE, new_bitmap);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
|
||||
|
||||
// Recompute UV coordinates for all cached glyphs
|
||||
atlas_wf : f32 = xx new_w;
|
||||
atlas_hf : f32 = xx new_h;
|
||||
i : s64 = 0;
|
||||
while i < self.entries.len {
|
||||
g := @self.entries.items[i].glyph;
|
||||
if g.width > 0.0 {
|
||||
g.uv_x = g.uv_x / 2.0;
|
||||
g.uv_y = g.uv_y / 2.0;
|
||||
g.uv_w = g.width / atlas_wf;
|
||||
g.uv_h = g.height / atlas_hf;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
self.dirty = false;
|
||||
out("GlyphCache atlas grown\n");
|
||||
}
|
||||
|
||||
set_dpi_scale :: (self: *GlyphCache, scale: f32) {
|
||||
self.dpi_scale = scale;
|
||||
self.inv_dpi = 1.0 / scale;
|
||||
}
|
||||
|
||||
// Get the scale factor for a logical font size
|
||||
scale_for_size :: (self: *GlyphCache, font_size: f32) -> f32 {
|
||||
stbtt_ScaleForPixelHeight(self.font_info, font_size);
|
||||
}
|
||||
|
||||
// Get scaled ascent for a logical font size
|
||||
get_ascent :: (self: *GlyphCache, font_size: f32) -> f32 {
|
||||
self.ascent * self.scale_for_size(font_size);
|
||||
}
|
||||
|
||||
// Get scaled line height for a logical font size
|
||||
get_line_height :: (self: *GlyphCache, font_size: f32) -> f32 {
|
||||
s := self.scale_for_size(font_size);
|
||||
(self.ascent - self.descent + self.line_gap) * s;
|
||||
}
|
||||
|
||||
// Shape text into positioned glyphs.
|
||||
// Uses ASCII fast-path (stbtt byte-by-byte) for pure ASCII,
|
||||
// full kb_text_shape pipeline for Unicode/complex scripts.
|
||||
// Results stored in self.shaped_buf (reused across calls).
|
||||
shape_text :: (self: *GlyphCache, text: string, font_size: f32) {
|
||||
self.shaped_buf.len = 0;
|
||||
if text.len == 0 { return; }
|
||||
|
||||
if is_ascii(text) {
|
||||
self.shape_ascii(text, font_size);
|
||||
} else {
|
||||
self.shape_with_kb(text, font_size);
|
||||
}
|
||||
}
|
||||
|
||||
shape_ascii :: (self: *GlyphCache, text: string, font_size: f32) {
|
||||
scale := stbtt_ScaleForPixelHeight(self.font_info, font_size);
|
||||
total : f32 = 0.0;
|
||||
i : s64 = 0;
|
||||
while i < text.len {
|
||||
ch : s32 = xx text[i];
|
||||
glyph_index : u16 = xx stbtt_FindGlyphIndex(self.font_info, ch);
|
||||
|
||||
advance_i : s32 = 0;
|
||||
lsb_i : s32 = 0;
|
||||
stbtt_GetGlyphHMetrics(self.font_info, xx glyph_index, @advance_i, @lsb_i);
|
||||
adv : f32 = xx advance_i * scale;
|
||||
|
||||
self.shaped_buf.append(ShapedGlyph.{
|
||||
glyph_index = glyph_index,
|
||||
x = total,
|
||||
y = 0.0,
|
||||
advance = adv
|
||||
});
|
||||
total += adv;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
shape_with_kb :: (self: *GlyphCache, text: string, font_size: f32) {
|
||||
if xx self.shape_ctx == 0 {
|
||||
self.shape_ascii(text, font_size);
|
||||
return;
|
||||
}
|
||||
|
||||
scale : f32 = font_size / xx self.units_per_em;
|
||||
total : f32 = 0.0;
|
||||
|
||||
kbts_ShapeBegin(xx self.shape_ctx, KBTS_DIRECTION_DONT_KNOW, KBTS_LANGUAGE_DONT_KNOW);
|
||||
kbts_ShapeUtf8(xx self.shape_ctx, xx text.ptr, xx text.len, KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX);
|
||||
kbts_ShapeEnd(xx self.shape_ctx);
|
||||
|
||||
run : KbtsRun = ---;
|
||||
while kbts_ShapeRun(xx self.shape_ctx, xx @run) != 0 {
|
||||
glyph_ptr : *KbtsGlyph = xx 0;
|
||||
while kbts_GlyphIteratorNext(xx @run.glyphs, xx @glyph_ptr) != 0 {
|
||||
if xx glyph_ptr == 0 { continue; }
|
||||
gx := total + xx glyph_ptr.offset_x * scale;
|
||||
gy : f32 = xx glyph_ptr.offset_y * scale;
|
||||
adv : f32 = xx glyph_ptr.advance_x * scale;
|
||||
|
||||
self.shaped_buf.append(ShapedGlyph.{
|
||||
glyph_index = glyph_ptr.id,
|
||||
x = gx,
|
||||
y = gy,
|
||||
advance = adv
|
||||
});
|
||||
total += adv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure text at a logical font size using text shaping.
|
||||
// Rasterizes at physical resolution (font_size * dpi_scale), returns logical dimensions.
|
||||
measure_text :: (self: *GlyphCache, text: string, font_size: f32) -> Size {
|
||||
self.shape_text(text, font_size);
|
||||
width : f32 = 0.0;
|
||||
i : s64 = 0;
|
||||
while i < self.shaped_buf.len {
|
||||
width += self.shaped_buf.items[i].advance;
|
||||
i += 1;
|
||||
}
|
||||
Size.{ width = width, height = self.get_line_height(font_size) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user