Shape a text once per width, not once per ask
A container measures a child by drawing it in a box it may not keep, so one layout asks a text for a dozen widths and comes back to widths it has already had -- the hottest text in the depth-8 tree draws 32 times. Each ask re-ran the shaper, because the two caches in front of it held one entry each and a trial width alternating with a final width evicts the answer about to be wanted again. `perf record` put 63% of a resize frame in text and 0.9% in `draw_inner`. So keep more than one: a bounded store of shapings on `TextData`, keyed by the text, the attrs and the width, holding the parley layout and the glyphs placed from it. Bounding the store rather than each buffer is what keeps it a fixed cost -- +4 MB on a tree of 4,000 texts, which is 19 MB less than the code before #16 holds after the same resizes. `TextBuffer` now holds the glyphs of the shaping it is drawn as, which is where `TextView::tex` was. That leaves one place to invalidate rather than two, so the `MutDetect` flags on a view's text and attrs have no reader and go, along with the `buf.changed = true` after every edit. On a 40-row tree of distinct random paragraphs, 500 resize frames: 124.2M instructions per frame before, 17.7M after, and 45.9M when the width never repeats. The five reference renders and the resize render are byte-identical, and the 100-seed sweep passes. `tests/revision_cost.rs` is that tree, written in the API subset `43ce8c7` shares so the same source measures the code this replaced. Report the worst frame and p99 beside the median, since a stutter is what somebody sees. Count glyph placements, and count a text render per ask rather than per shaping, so the store cannot hide how many times a layout drew the same text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f1a47e9b7b
commit
e5f8b6b244
7 files changed
+321
-43
No files matched your search
@@ -54,10 +54,11 @@ pub(crate) enum Counter {
|
||||
TextRenders,
|
||||
TextShapeHits,
|
||||
TextShapes,
|
||||
GlyphPlacements,
|
||||
}
|
||||
|
||||
impl Counter {
|
||||
const COUNT: usize = Self::TextShapes as usize + 1;
|
||||
const COUNT: usize = Self::GlyphPlacements as usize + 1;
|
||||
|
||||
const NAMES: [&'static str; Self::COUNT] = [
|
||||
"updates",
|
||||
@@ -89,6 +90,7 @@ impl Counter {
|
||||
"text renders",
|
||||
"text shape hits",
|
||||
"text shapes",
|
||||
"glyph placements",
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+96
-13
@@ -7,7 +7,10 @@ use parley::{
|
||||
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout,
|
||||
LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
||||
};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
};
|
||||
use swash::{
|
||||
FontRef,
|
||||
scale::{Render, ScaleContext, Source, StrikeWith},
|
||||
@@ -19,8 +22,31 @@ pub struct TextData {
|
||||
pub layout_ctx: LayoutContext<UiColor>,
|
||||
scale_ctx: ScaleContext,
|
||||
pub atlas: GlyphAtlas,
|
||||
spare: VecDeque<Shaping>,
|
||||
}
|
||||
|
||||
/// One shaping of some text, and the glyphs placed from it. A buffer holds
|
||||
/// the one it is drawn as; these are the ones it had before, kept because a
|
||||
/// container measures a child by drawing it in a box it may not keep, and so
|
||||
/// comes back to widths it has already asked for.
|
||||
struct Shaping {
|
||||
/// A shaping is a function of these three and nothing else, so no widget
|
||||
/// or buffer identity is involved and two texts of the same words share
|
||||
/// an answer.
|
||||
text: String,
|
||||
key: LayoutKey,
|
||||
layout: Layout<UiColor>,
|
||||
placed: Option<RenderedText>,
|
||||
}
|
||||
|
||||
/// How many to keep. Bounding the whole cache rather than each buffer is
|
||||
/// what makes this a fixed cost instead of one a tree of ten thousand texts
|
||||
/// pays ten thousand times; the re-asks come from laying out one subtree, so
|
||||
/// they are close together and few are needed. On `tests/revision_cost.rs`
|
||||
/// under `SWEEP=1`, the case that cannot hit across frames, 32 is not enough
|
||||
/// (6.6 ms) and 64 is (4.4 ms).
|
||||
const SPARE_SHAPINGS: usize = 128;
|
||||
|
||||
impl Default for TextData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -28,6 +54,7 @@ impl Default for TextData {
|
||||
layout_ctx: LayoutContext::new(),
|
||||
scale_ctx: ScaleContext::new(),
|
||||
atlas: GlyphAtlas::default(),
|
||||
spare: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +110,9 @@ pub struct TextBuffer {
|
||||
text: String,
|
||||
layout: Layout<UiColor>,
|
||||
layout_key: Option<LayoutKey>,
|
||||
/// The glyphs placed from `layout`, so drawing this text again at the
|
||||
/// width it already has places them once.
|
||||
placed: Option<RenderedText>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
@@ -97,6 +127,7 @@ impl TextBuffer {
|
||||
text: text.into(),
|
||||
layout: Layout::new(),
|
||||
layout_key: None,
|
||||
placed: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,15 +152,28 @@ impl TextBuffer {
|
||||
if text != self.text {
|
||||
self.text = text;
|
||||
self.layout_key = None;
|
||||
self.placed = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidates the layout and returns the underlying string for editing.
|
||||
pub fn edit(&mut self) -> &mut String {
|
||||
self.layout_key = None;
|
||||
self.placed = None;
|
||||
&mut self.text
|
||||
}
|
||||
|
||||
/// The glyphs of the shaping it is drawn as, once they are placed.
|
||||
pub fn rendered(&self) -> Option<&RenderedText> {
|
||||
self.placed.as_ref()
|
||||
}
|
||||
|
||||
/// The width its shaping wraps at, and `None` where it does not wrap or
|
||||
/// has not been shaped.
|
||||
pub fn wrap_width(&self) -> Option<f32> {
|
||||
self.layout_key.as_ref()?.max_width
|
||||
}
|
||||
|
||||
pub fn size(&self) -> Vec2 {
|
||||
Vec2::new(self.layout.width(), self.layout.height())
|
||||
}
|
||||
@@ -144,6 +188,23 @@ impl TextBuffer {
|
||||
diag::bump(Counter::TextShapeHits);
|
||||
return;
|
||||
}
|
||||
let kept = data.take_shaping(&self.text, &layout_key);
|
||||
if let Some(key) = self.layout_key.take() {
|
||||
data.keep_shaping(Shaping {
|
||||
text: self.text.clone(),
|
||||
key,
|
||||
layout: std::mem::replace(&mut self.layout, Layout::new()),
|
||||
placed: self.placed.take(),
|
||||
});
|
||||
}
|
||||
if let Some(shaping) = kept {
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
diag::bump(Counter::TextShapeHits);
|
||||
self.layout = shaping.layout;
|
||||
self.placed = shaping.placed;
|
||||
self.layout_key = Some(shaping.key);
|
||||
return;
|
||||
}
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
diag::bump(Counter::TextShapes);
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
@@ -273,26 +334,48 @@ pub struct RenderedText {
|
||||
}
|
||||
|
||||
impl TextData {
|
||||
pub fn render(
|
||||
/// The shaping for this text at this width, taken out of what is kept.
|
||||
fn take_shaping(&mut self, text: &str, key: &LayoutKey) -> Option<Shaping> {
|
||||
// From the newest, since a re-ask is usually of something recent.
|
||||
let at = self
|
||||
.spare
|
||||
.iter()
|
||||
.rposition(|spare| spare.key == *key && spare.text == text)?;
|
||||
self.spare.remove(at)
|
||||
}
|
||||
|
||||
fn keep_shaping(&mut self, shaping: Shaping) {
|
||||
if self.spare.len() >= SPARE_SHAPINGS {
|
||||
self.spare.pop_front();
|
||||
}
|
||||
self.spare.push_back(shaping);
|
||||
}
|
||||
|
||||
pub fn render<'b>(
|
||||
&mut self,
|
||||
buffer: &mut TextBuffer,
|
||||
buffer: &'b mut TextBuffer,
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
) -> RenderedText {
|
||||
) -> &'b RenderedText {
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
diag::bump(Counter::TextRenders);
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
let _render = diag::timer(TimerKind::TextRender);
|
||||
buffer.shape(self, attrs, width);
|
||||
let glyphs = {
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
let _place = diag::timer(TimerKind::GlyphPlacement);
|
||||
self.place(buffer)
|
||||
let placed = match buffer.placed.take() {
|
||||
Some(placed) => placed,
|
||||
None => {
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
diag::bump(Counter::GlyphPlacements);
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
let _place = diag::timer(TimerKind::GlyphPlacement);
|
||||
RenderedText {
|
||||
glyphs: self.place(buffer),
|
||||
size: buffer.size(),
|
||||
color: attrs.color,
|
||||
}
|
||||
}
|
||||
};
|
||||
RenderedText {
|
||||
glyphs,
|
||||
size: buffer.size(),
|
||||
color: attrs.color,
|
||||
}
|
||||
buffer.placed.insert(placed)
|
||||
}
|
||||
}
|
||||
@@ -239,12 +239,12 @@ impl<'a> Painter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_text(
|
||||
pub fn render_text<'b>(
|
||||
&mut self,
|
||||
buffer: &mut TextBuffer,
|
||||
buffer: &'b mut TextBuffer,
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
) -> RenderedText {
|
||||
) -> &'b RenderedText {
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
diag::render_text(self.id, self.rsc.widgets().label(self.id), width);
|
||||
let ui = self.rsc.ui_mut();
|
||||
|
||||
@@ -130,7 +130,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
pub fn set(&mut self, text: &str) {
|
||||
let text = self.string(text);
|
||||
self.text.view.buf.set_text(text);
|
||||
self.text.view.buf.changed = true;
|
||||
self.text.selection = None;
|
||||
}
|
||||
|
||||
@@ -177,7 +176,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
};
|
||||
let at = at.min(self.text.view.buf.text().len());
|
||||
self.text.view.buf.edit().insert_str(at, text);
|
||||
self.text.view.buf.changed = true;
|
||||
self.set_caret(at + text.len());
|
||||
}
|
||||
|
||||
@@ -190,7 +188,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
}
|
||||
let range = sel.text_range();
|
||||
self.text.view.buf.edit().replace_range(range.clone(), "");
|
||||
self.text.view.buf.changed = true;
|
||||
self.set_caret(range.start);
|
||||
true
|
||||
}
|
||||
@@ -268,7 +265,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
|
||||
fn delete_range(&mut self, start: usize, end: usize) {
|
||||
self.text.view.buf.edit().replace_range(start..end, "");
|
||||
self.text.view.buf.changed = true;
|
||||
self.set_caret(start);
|
||||
}
|
||||
|
||||
|
||||
+10
-21
@@ -14,11 +14,8 @@ pub struct Text {
|
||||
}
|
||||
|
||||
pub struct TextView {
|
||||
pub attrs: MutDetect<TextAttrs>,
|
||||
pub buf: MutDetect<TextBuffer>,
|
||||
// cache
|
||||
tex: Option<RenderedText>,
|
||||
width: Option<f32>,
|
||||
pub attrs: TextAttrs,
|
||||
pub buf: TextBuffer,
|
||||
pub hint: Option<StrongWidget>,
|
||||
}
|
||||
|
||||
@@ -28,19 +25,13 @@ impl TextView {
|
||||
}
|
||||
|
||||
pub fn wrap_width(&self) -> Option<f32> {
|
||||
self.width
|
||||
self.buf.wrap_width()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextView {
|
||||
pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option<StrongWidget>) -> Self {
|
||||
Self {
|
||||
attrs: attrs.into(),
|
||||
buf: buf.into(),
|
||||
tex: None,
|
||||
width: None,
|
||||
hint,
|
||||
}
|
||||
Self { attrs, buf, hint }
|
||||
}
|
||||
|
||||
/// region where the text should be draw
|
||||
@@ -52,22 +43,20 @@ impl TextView {
|
||||
.align(self.align)
|
||||
}
|
||||
|
||||
/// The text shaped for the width it is drawn in. The buffer keeps its
|
||||
/// answers under the attrs too, so changing those asks a new question
|
||||
/// rather than invalidating anything.
|
||||
fn render(&mut self, painter: &mut Painter) -> &RenderedText {
|
||||
let width = if self.attrs.wrap {
|
||||
Some(painter.px_len(Axis::X))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if width != self.width || self.tex.is_none() || self.attrs.changed || self.buf.changed {
|
||||
self.width = width;
|
||||
self.tex = Some(painter.render_text(&mut self.buf, &self.attrs, width));
|
||||
self.attrs.changed = false;
|
||||
self.buf.changed = false;
|
||||
}
|
||||
self.tex.as_ref().unwrap()
|
||||
painter.render_text(&mut self.buf, &self.attrs, width)
|
||||
}
|
||||
|
||||
pub fn tex(&self) -> Option<&RenderedText> {
|
||||
self.tex.as_ref()
|
||||
self.buf.rendered()
|
||||
}
|
||||
/// Draws the text, and says where the glyphs went and what they use.
|
||||
pub fn draw(&mut self, painter: &mut Painter) -> (UiRegion, Size) {
|
||||
|
||||
@@ -109,10 +109,15 @@ fn warm(seed: u64, depth: usize) -> (Harness, Tree) {
|
||||
fn report(label: &str, mut elapsed: Vec<f64>, _harness: &Harness) {
|
||||
elapsed.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let frames = elapsed.len();
|
||||
// The worst frame is the stutter somebody sees, so it goes beside the
|
||||
// median; p99 says whether it is the load or a single interruption.
|
||||
println!(
|
||||
"{label}: {frames} frame(s), min {:.3} ms, median {:.3} ms, total {:.1} ms",
|
||||
"{label}: {frames} frame(s), min {:.3} ms, median {:.3} ms, p99 {:.3} ms, \
|
||||
max {:.3} ms, total {:.1} ms",
|
||||
elapsed[0],
|
||||
elapsed[frames / 2],
|
||||
elapsed[frames * 99 / 100],
|
||||
elapsed[frames - 1],
|
||||
elapsed.iter().sum::<f64>(),
|
||||
);
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! What a resize frame costs and what it holds, on a tree the revision before
|
||||
//! #16 also builds.
|
||||
//!
|
||||
//! Deliberately written in the API subset `43ce8c7` and this branch share, so
|
||||
//! the same source can be dropped into an old worktree and measured there:
|
||||
//! that is the only like-for-like comparison with the code the retained
|
||||
//! layout replaced. The random tree cannot carry one, because the generator
|
||||
//! itself changed with the work.
|
||||
//!
|
||||
//! ROWS=40 FRAMES=500 cargo test --release --test revision_cost \
|
||||
//! -- --ignored --nocapture resize_cost
|
||||
//! ROWS=2000 cargo test --release --test revision_cost \
|
||||
//! -- --ignored --nocapture text_memory
|
||||
//!
|
||||
//! Wall time on this machine varies with CPU frequency; take the number from
|
||||
//! `perf stat -e instructions:u` on the test binary directly.
|
||||
|
||||
use iris::harness::Harness;
|
||||
use iris::prelude::*;
|
||||
use std::time::Instant;
|
||||
|
||||
/// xorshift64, so one seed is one set of paragraphs on any machine.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn bits(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.bits() % n as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
const WORDS: [&str; 24] = [
|
||||
"wrapping",
|
||||
"shapes",
|
||||
"one",
|
||||
"source",
|
||||
"into",
|
||||
"as",
|
||||
"many",
|
||||
"lines",
|
||||
"as",
|
||||
"the",
|
||||
"box",
|
||||
"leaves",
|
||||
"room",
|
||||
"for",
|
||||
"paragraph",
|
||||
"height",
|
||||
"answer",
|
||||
"setting",
|
||||
"container",
|
||||
"width",
|
||||
"before",
|
||||
"knows",
|
||||
"measured",
|
||||
"again",
|
||||
];
|
||||
|
||||
/// A run of its own words, so nothing here is fast for two texts being the
|
||||
/// same string.
|
||||
fn words(rng: &mut Rng, least: usize, most: usize) -> String {
|
||||
let words = least + rng.below(most - least);
|
||||
let mut out = String::new();
|
||||
for _ in 0..words {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(WORDS[rng.below(WORDS.len())]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const OUTPUT: (f32, f32) = (900.0, 1200.0);
|
||||
|
||||
fn env<T: std::str::FromStr>(name: &str, fallback: T) -> T {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// A row of a fixed-width rect beside a column of one wrapping and one
|
||||
/// overflowing text: the shape that makes a container measure a child in a
|
||||
/// box it will not keep.
|
||||
fn build(h: &mut Harness, rows: usize) -> Vec<WidgetId> {
|
||||
let mut rng = Rng(1);
|
||||
let mut paragraphs = Vec::new();
|
||||
let mut col = Span::empty(Dir::DOWN);
|
||||
for _ in 0..rows {
|
||||
let mut row = Span::empty(Dir::RIGHT);
|
||||
row.push(
|
||||
rect(Color::RED)
|
||||
.width(Len::abs(40.0))
|
||||
.add_strong(&mut h.rsc),
|
||||
);
|
||||
let mut body = Span::empty(Dir::DOWN);
|
||||
let para = wtext(words(&mut rng, 12, 52))
|
||||
.size(16)
|
||||
.wrap(true)
|
||||
.add_strong(&mut h.rsc);
|
||||
paragraphs.push(para.id());
|
||||
body.push(para);
|
||||
body.push(
|
||||
// Short, or its unwrapped width decides the row and the
|
||||
// paragraph beside it never wraps.
|
||||
wtext(words(&mut rng, 2, 6))
|
||||
.size(16)
|
||||
.wrap(false)
|
||||
.add_strong(&mut h.rsc),
|
||||
);
|
||||
row.push(body.add_strong(&mut h.rsc));
|
||||
col.push(row.add_strong(&mut h.rsc));
|
||||
}
|
||||
let root = col.add(&mut h.rsc);
|
||||
h.set_root(root);
|
||||
paragraphs
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "measurement, not a check"]
|
||||
fn resize_cost() {
|
||||
let rows = env("ROWS", 40_usize);
|
||||
let frames = env("FRAMES", 500_usize);
|
||||
let mut h = Harness::new(OUTPUT);
|
||||
let paragraphs = build(&mut h, rows);
|
||||
// What it cost is only half the comparison: the old code is cheaper
|
||||
// partly because it wraps at the container's whole width rather than the
|
||||
// part left beside the rect, and draws past the edge of the output.
|
||||
println!("output width {}", OUTPUT.0);
|
||||
for (at, id) in paragraphs.iter().enumerate().take(3) {
|
||||
println!("paragraph {at}: {:?}", h.region(id));
|
||||
}
|
||||
|
||||
// Two widths in turn is the friendly case for anything that remembers an
|
||||
// answer, so `SWEEP=1` never repeats one -- a drag rather than a toggle.
|
||||
let sweep = env("SWEEP", 0_usize) != 0;
|
||||
let mut elapsed = Vec::with_capacity(frames);
|
||||
for frame in 0..frames {
|
||||
let narrower = match sweep {
|
||||
true => (frame % 256) as f32,
|
||||
false => ((frame + 1) % 2) as f32 * 8.0,
|
||||
};
|
||||
h.resize((OUTPUT.0 - narrower, OUTPUT.1));
|
||||
let start = Instant::now();
|
||||
h.frame();
|
||||
elapsed.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
elapsed.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
println!(
|
||||
"resize: {frames} frames, min {:.3} ms, median {:.3} ms, p99 {:.3} ms, \
|
||||
max {:.3} ms, total {:.1} ms",
|
||||
elapsed[0],
|
||||
elapsed[frames / 2],
|
||||
elapsed[frames * 99 / 100],
|
||||
elapsed[frames - 1],
|
||||
elapsed.iter().sum::<f64>()
|
||||
);
|
||||
}
|
||||
|
||||
fn kb(field: &str) -> u64 {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
.unwrap()
|
||||
.lines()
|
||||
.find(|line| line.starts_with(field))
|
||||
.and_then(|line| line.split_whitespace().nth(1)?.parse().ok())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn report(label: &str) {
|
||||
println!(
|
||||
"{label:24} rss {:>7} kB peak {:>7} kB",
|
||||
kb("VmRSS:"),
|
||||
kb("VmHWM:")
|
||||
);
|
||||
}
|
||||
|
||||
/// Run this one on its own: the figures are the whole process's.
|
||||
#[test]
|
||||
#[ignore = "measurement, not a check"]
|
||||
fn text_memory() {
|
||||
let rows = env("ROWS", 2000_usize);
|
||||
report("before");
|
||||
let mut h = Harness::new(OUTPUT);
|
||||
let paragraphs = build(&mut h, rows);
|
||||
report("after cold frame");
|
||||
for frame in 0..40 {
|
||||
h.resize((OUTPUT.0 - ((frame + 1) % 2) as f32 * 8.0, OUTPUT.1));
|
||||
h.frame();
|
||||
}
|
||||
report("after 40 resizes");
|
||||
// Settled: the output holds still and one leaf repaints per frame.
|
||||
for _ in 0..10 {
|
||||
let _ = h.rsc.widgets_mut().get_dyn_mut(paragraphs[0]);
|
||||
h.frame();
|
||||
}
|
||||
report("after settling");
|
||||
}
|
||||
Reference in new issue
Block a user