Implement the Overflow::Hidden
style property for UI (#3296)
# Objective This PR implements the `overflow` style property in `bevy_ui`. When set to `Overflow::Hidden`, the children of that node are clipped so that overflowing parts are not rendered. This is an important building block for UI widgets. ## Solution Clipping is done on the CPU so that it does not break batching. The clip regions update was implemented as a separate system for clarity, but it could be merged with the other UI systems to avoid doing an additional tree traversal. (I don't think it's important until we fix the layout performance issues though). A scrolling list was added to the `ui_pipelined` example to showcase `Overflow::Hidden`. For the sake of simplicity, it can only be scrolled with a mouse.
This commit is contained in:
parent
9a89295a17
commit
340957994d
@ -1,9 +1,10 @@
|
||||
use bevy_math::Vec2;
|
||||
use bevy_reflect::Reflect;
|
||||
|
||||
/// A rectangle defined by two points. There is no defined origin, so 0,0 could be anywhere
|
||||
/// (top-left, bottom-left, etc)
|
||||
#[repr(C)]
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
#[derive(Default, Clone, Copy, Debug, Reflect)]
|
||||
pub struct Rect {
|
||||
/// The beginning point of the rect
|
||||
pub min: Vec2,
|
||||
|
@ -26,7 +26,7 @@ use bevy_ecs::schedule::{ParallelSystemDescriptorCoercion, SystemLabel};
|
||||
use bevy_input::InputSystem;
|
||||
use bevy_math::{Rect, Size};
|
||||
use bevy_transform::TransformSystem;
|
||||
use update::ui_z_system;
|
||||
use update::{ui_z_system, update_clipping_system};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UiPlugin;
|
||||
@ -89,6 +89,10 @@ impl Plugin for UiPlugin {
|
||||
ui_z_system
|
||||
.after(UiSystem::Flex)
|
||||
.before(TransformSystem::TransformPropagate),
|
||||
)
|
||||
.add_system_to_stage(
|
||||
CoreStage::PostUpdate,
|
||||
update_clipping_system.after(TransformSystem::TransformPropagate),
|
||||
);
|
||||
|
||||
crate::render::build_ui_render(app);
|
||||
|
@ -26,7 +26,7 @@ use bevy_render::{
|
||||
view::ViewUniforms,
|
||||
RenderApp, RenderStage, RenderWorld,
|
||||
};
|
||||
use bevy_sprite::{SpriteAssetEvents, TextureAtlas};
|
||||
use bevy_sprite::{Rect, SpriteAssetEvents, TextureAtlas};
|
||||
use bevy_text::{DefaultTextPipeline, Text};
|
||||
use bevy_transform::components::GlobalTransform;
|
||||
use bevy_utils::HashMap;
|
||||
@ -34,7 +34,7 @@ use bevy_window::Windows;
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
|
||||
use crate::{Node, UiColor, UiImage};
|
||||
use crate::{CalculatedClip, Node, UiColor, UiImage};
|
||||
|
||||
pub mod node {
|
||||
pub const UI_PASS_DRIVER: &str = "ui_pass_driver";
|
||||
@ -120,9 +120,10 @@ pub fn build_ui_render(app: &mut App) {
|
||||
pub struct ExtractedUiNode {
|
||||
pub transform: Mat4,
|
||||
pub color: Color,
|
||||
pub rect: bevy_sprite::Rect,
|
||||
pub rect: Rect,
|
||||
pub image: Handle<Image>,
|
||||
pub atlas_size: Option<Vec2>,
|
||||
pub clip: Option<Rect>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@ -133,11 +134,17 @@ pub struct ExtractedUiNodes {
|
||||
pub fn extract_uinodes(
|
||||
mut render_world: ResMut<RenderWorld>,
|
||||
images: Res<Assets<Image>>,
|
||||
uinode_query: Query<(&Node, &GlobalTransform, &UiColor, &UiImage)>,
|
||||
uinode_query: Query<(
|
||||
&Node,
|
||||
&GlobalTransform,
|
||||
&UiColor,
|
||||
&UiImage,
|
||||
Option<&CalculatedClip>,
|
||||
)>,
|
||||
) {
|
||||
let mut extracted_uinodes = render_world.get_resource_mut::<ExtractedUiNodes>().unwrap();
|
||||
extracted_uinodes.uinodes.clear();
|
||||
for (uinode, transform, color, image) in uinode_query.iter() {
|
||||
for (uinode, transform, color, image, clip) in uinode_query.iter() {
|
||||
let image = image.0.clone_weak();
|
||||
// Skip loading images
|
||||
if !images.contains(image.clone_weak()) {
|
||||
@ -152,6 +159,7 @@ pub fn extract_uinodes(
|
||||
},
|
||||
image,
|
||||
atlas_size: None,
|
||||
clip: clip.map(|clip| clip.clip),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -161,7 +169,13 @@ pub fn extract_text_uinodes(
|
||||
texture_atlases: Res<Assets<TextureAtlas>>,
|
||||
text_pipeline: Res<DefaultTextPipeline>,
|
||||
windows: Res<Windows>,
|
||||
uinode_query: Query<(Entity, &Node, &GlobalTransform, &Text)>,
|
||||
uinode_query: Query<(
|
||||
Entity,
|
||||
&Node,
|
||||
&GlobalTransform,
|
||||
&Text,
|
||||
Option<&CalculatedClip>,
|
||||
)>,
|
||||
) {
|
||||
let mut extracted_uinodes = render_world.get_resource_mut::<ExtractedUiNodes>().unwrap();
|
||||
|
||||
@ -171,7 +185,7 @@ pub fn extract_text_uinodes(
|
||||
1.
|
||||
};
|
||||
|
||||
for (entity, uinode, transform, text) in uinode_query.iter() {
|
||||
for (entity, uinode, transform, text, clip) in uinode_query.iter() {
|
||||
// Skip if size is set to zero (e.g. when a parent is set to `Display::None`)
|
||||
if uinode.size == Vec2::ZERO {
|
||||
continue;
|
||||
@ -203,6 +217,7 @@ pub fn extract_text_uinodes(
|
||||
rect,
|
||||
image: texture,
|
||||
atlas_size,
|
||||
clip: clip.map(|clip| clip.clip),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -231,15 +246,15 @@ impl Default for UiMeta {
|
||||
}
|
||||
}
|
||||
|
||||
const QUAD_VERTEX_POSITIONS: &[Vec3] = &[
|
||||
const_vec3!([-0.5, -0.5, 0.0]),
|
||||
const_vec3!([0.5, 0.5, 0.0]),
|
||||
const_vec3!([-0.5, 0.5, 0.0]),
|
||||
const QUAD_VERTEX_POSITIONS: [Vec3; 4] = [
|
||||
const_vec3!([-0.5, -0.5, 0.0]),
|
||||
const_vec3!([0.5, -0.5, 0.0]),
|
||||
const_vec3!([0.5, 0.5, 0.0]),
|
||||
const_vec3!([-0.5, 0.5, 0.0]),
|
||||
];
|
||||
|
||||
const QUAD_INDICES: [usize; 6] = [0, 2, 3, 0, 1, 2];
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct UiBatch {
|
||||
pub range: Range<u32>,
|
||||
@ -279,47 +294,90 @@ pub fn prepare_uinodes(
|
||||
}
|
||||
|
||||
let uinode_rect = extracted_uinode.rect;
|
||||
let rect_size = uinode_rect.size().extend(1.0);
|
||||
|
||||
// Specify the corners of the node
|
||||
let mut bottom_left = Vec2::new(uinode_rect.min.x, uinode_rect.max.y);
|
||||
let mut top_left = uinode_rect.min;
|
||||
let mut top_right = Vec2::new(uinode_rect.max.x, uinode_rect.min.y);
|
||||
let mut bottom_right = uinode_rect.max;
|
||||
let positions = QUAD_VERTEX_POSITIONS
|
||||
.map(|pos| (extracted_uinode.transform * (pos * rect_size).extend(1.)).xyz());
|
||||
|
||||
let atlas_extent = extracted_uinode.atlas_size.unwrap_or(uinode_rect.max);
|
||||
bottom_left /= atlas_extent;
|
||||
bottom_right /= atlas_extent;
|
||||
top_left /= atlas_extent;
|
||||
top_right /= atlas_extent;
|
||||
// Calculate the effect of clipping
|
||||
// Note: this won't work with rotation/scaling, but that's much more complex (may need more that 2 quads)
|
||||
let positions_diff = if let Some(clip) = extracted_uinode.clip {
|
||||
[
|
||||
Vec2::new(
|
||||
f32::max(clip.min.x - positions[0].x, 0.),
|
||||
f32::max(clip.min.y - positions[0].y, 0.),
|
||||
),
|
||||
Vec2::new(
|
||||
f32::min(clip.max.x - positions[1].x, 0.),
|
||||
f32::max(clip.min.y - positions[1].y, 0.),
|
||||
),
|
||||
Vec2::new(
|
||||
f32::min(clip.max.x - positions[2].x, 0.),
|
||||
f32::min(clip.max.y - positions[2].y, 0.),
|
||||
),
|
||||
Vec2::new(
|
||||
f32::max(clip.min.x - positions[3].x, 0.),
|
||||
f32::min(clip.max.y - positions[3].y, 0.),
|
||||
),
|
||||
]
|
||||
} else {
|
||||
[Vec2::ZERO; 4]
|
||||
};
|
||||
|
||||
let uvs: [[f32; 2]; 6] = [
|
||||
bottom_left.into(),
|
||||
top_right.into(),
|
||||
top_left.into(),
|
||||
bottom_left.into(),
|
||||
bottom_right.into(),
|
||||
top_right.into(),
|
||||
let positions_clipped = [
|
||||
positions[0] + positions_diff[0].extend(0.),
|
||||
positions[1] + positions_diff[1].extend(0.),
|
||||
positions[2] + positions_diff[2].extend(0.),
|
||||
positions[3] + positions_diff[3].extend(0.),
|
||||
];
|
||||
|
||||
let rect_size = extracted_uinode.rect.size().extend(1.0);
|
||||
// Cull nodes that are completely clipped
|
||||
if positions_diff[0].x - positions_diff[1].x >= rect_size.x
|
||||
|| positions_diff[1].y - positions_diff[2].y >= rect_size.y
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clip UVs (Note: y is reversed in UV space)
|
||||
let atlas_extent = extracted_uinode.atlas_size.unwrap_or(uinode_rect.max);
|
||||
let uvs = [
|
||||
Vec2::new(
|
||||
uinode_rect.min.x + positions_diff[0].x,
|
||||
uinode_rect.max.y - positions_diff[0].y,
|
||||
),
|
||||
Vec2::new(
|
||||
uinode_rect.max.x + positions_diff[1].x,
|
||||
uinode_rect.max.y - positions_diff[1].y,
|
||||
),
|
||||
Vec2::new(
|
||||
uinode_rect.max.x + positions_diff[2].x,
|
||||
uinode_rect.min.y - positions_diff[2].y,
|
||||
),
|
||||
Vec2::new(
|
||||
uinode_rect.min.x + positions_diff[3].x,
|
||||
uinode_rect.min.y - positions_diff[3].y,
|
||||
),
|
||||
]
|
||||
.map(|pos| pos / atlas_extent);
|
||||
|
||||
let color = extracted_uinode.color.as_linear_rgba_f32();
|
||||
// encode color as a single u32 to save space
|
||||
let color = (color[0] * 255.0) as u32
|
||||
| ((color[1] * 255.0) as u32) << 8
|
||||
| ((color[2] * 255.0) as u32) << 16
|
||||
| ((color[3] * 255.0) as u32) << 24;
|
||||
for (index, vertex_position) in QUAD_VERTEX_POSITIONS.iter().enumerate() {
|
||||
let mut final_position = *vertex_position * rect_size;
|
||||
final_position = (extracted_uinode.transform * final_position.extend(1.0)).xyz();
|
||||
|
||||
for i in QUAD_INDICES {
|
||||
ui_meta.vertices.push(UiVertex {
|
||||
position: final_position.into(),
|
||||
uv: uvs[index],
|
||||
position: positions_clipped[i].into(),
|
||||
uv: uvs[i].into(),
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
last_z = extracted_uinode.transform.w_axis[2];
|
||||
end += QUAD_VERTEX_POSITIONS.len() as u32;
|
||||
end += QUAD_INDICES.len() as u32;
|
||||
}
|
||||
|
||||
// if start != end, there is one last batch to process
|
||||
|
@ -76,6 +76,7 @@ pub struct Style {
|
||||
pub min_size: Size<Val>,
|
||||
pub max_size: Size<Val>,
|
||||
pub aspect_ratio: Option<f32>,
|
||||
pub overflow: Overflow,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
@ -101,6 +102,7 @@ impl Default for Style {
|
||||
min_size: Size::new(Val::Auto, Val::Auto),
|
||||
max_size: Size::new(Val::Auto, Val::Auto),
|
||||
aspect_ratio: Default::default(),
|
||||
overflow: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -214,19 +216,19 @@ impl Default for JustifyContent {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add support for overflow settings
|
||||
// #[derive(Copy, Clone, PartialEq, Debug)]
|
||||
// pub enum Overflow {
|
||||
// Visible,
|
||||
// Hidden,
|
||||
// Scroll,
|
||||
// }
|
||||
#[derive(Copy, Clone, PartialEq, Debug, Reflect, Serialize, Deserialize)]
|
||||
#[reflect_value(PartialEq, Serialize, Deserialize)]
|
||||
pub enum Overflow {
|
||||
Visible,
|
||||
Hidden,
|
||||
// Scroll,
|
||||
}
|
||||
|
||||
// impl Default for Overflow {
|
||||
// fn default() -> Overflow {
|
||||
// Overflow::Visible
|
||||
// }
|
||||
// }
|
||||
impl Default for Overflow {
|
||||
fn default() -> Overflow {
|
||||
Overflow::Visible
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize, Reflect)]
|
||||
#[reflect_value(PartialEq, Serialize, Deserialize)]
|
||||
@ -286,3 +288,9 @@ impl From<Handle<Image>> for UiImage {
|
||||
Self(handle)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Default, Copy, Clone, Debug, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct CalculatedClip {
|
||||
pub clip: bevy_sprite::Rect,
|
||||
}
|
||||
|
@ -1,10 +1,17 @@
|
||||
use crate::{CalculatedClip, Overflow, Style};
|
||||
|
||||
use super::Node;
|
||||
use bevy_ecs::{
|
||||
entity::Entity,
|
||||
query::{With, Without},
|
||||
system::Query,
|
||||
system::{Commands, Query},
|
||||
};
|
||||
use bevy_math::Vec2;
|
||||
use bevy_sprite::Rect;
|
||||
use bevy_transform::{
|
||||
components::GlobalTransform,
|
||||
prelude::{Children, Parent, Transform},
|
||||
};
|
||||
use bevy_transform::prelude::{Children, Parent, Transform};
|
||||
|
||||
pub const UI_Z_STEP: f32 = 0.001;
|
||||
|
||||
@ -50,6 +57,73 @@ fn update_hierarchy(
|
||||
}
|
||||
current_global_z
|
||||
}
|
||||
|
||||
pub fn update_clipping_system(
|
||||
mut commands: Commands,
|
||||
root_node_query: Query<Entity, (With<Node>, Without<Parent>)>,
|
||||
mut node_query: Query<(&Node, &GlobalTransform, &Style, Option<&mut CalculatedClip>)>,
|
||||
children_query: Query<&Children>,
|
||||
) {
|
||||
for root_node in root_node_query.iter() {
|
||||
update_clipping(
|
||||
&mut commands,
|
||||
&children_query,
|
||||
&mut node_query,
|
||||
root_node,
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn update_clipping(
|
||||
commands: &mut Commands,
|
||||
children_query: &Query<&Children>,
|
||||
node_query: &mut Query<(&Node, &GlobalTransform, &Style, Option<&mut CalculatedClip>)>,
|
||||
entity: Entity,
|
||||
clip: Option<Rect>,
|
||||
) {
|
||||
let (node, global_transform, style, calculated_clip) = node_query.get_mut(entity).unwrap();
|
||||
// Update this node's CalculatedClip component
|
||||
match (clip, calculated_clip) {
|
||||
(None, None) => {}
|
||||
(None, Some(_)) => {
|
||||
commands.entity(entity).remove::<CalculatedClip>();
|
||||
}
|
||||
(Some(clip), None) => {
|
||||
commands.entity(entity).insert(CalculatedClip { clip });
|
||||
}
|
||||
(Some(clip), Some(mut old_clip)) => {
|
||||
*old_clip = CalculatedClip { clip };
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate new clip for its children
|
||||
let children_clip = match style.overflow {
|
||||
Overflow::Visible => clip,
|
||||
Overflow::Hidden => {
|
||||
let node_center = global_transform.translation.truncate();
|
||||
let node_rect = Rect {
|
||||
min: node_center - node.size / 2.,
|
||||
max: node_center + node.size / 2.,
|
||||
};
|
||||
if let Some(clip) = clip {
|
||||
Some(Rect {
|
||||
min: Vec2::max(clip.min, node_rect.min),
|
||||
max: Vec2::min(clip.max, node_rect.max),
|
||||
})
|
||||
} else {
|
||||
Some(node_rect)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(children) = children_query.get(entity) {
|
||||
for child in children.iter().cloned() {
|
||||
update_clipping(commands, children_query, node_query, child, children_clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bevy_ecs::{
|
||||
|
@ -1,10 +1,14 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy::{
|
||||
input::mouse::{MouseScrollUnit, MouseWheel},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
/// This example illustrates the various features of Bevy UI.
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.add_startup_system(setup)
|
||||
.add_system(mouse_scroll)
|
||||
.run();
|
||||
}
|
||||
|
||||
@ -68,14 +72,97 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||
});
|
||||
});
|
||||
// right vertical fill
|
||||
parent.spawn_bundle(NodeBundle {
|
||||
style: Style {
|
||||
size: Size::new(Val::Px(200.0), Val::Percent(100.0)),
|
||||
parent
|
||||
.spawn_bundle(NodeBundle {
|
||||
style: Style {
|
||||
flex_direction: FlexDirection::ColumnReverse,
|
||||
justify_content: JustifyContent::Center,
|
||||
size: Size::new(Val::Px(200.0), Val::Percent(100.0)),
|
||||
..Default::default()
|
||||
},
|
||||
color: Color::rgb(0.15, 0.15, 0.15).into(),
|
||||
..Default::default()
|
||||
},
|
||||
color: Color::rgb(0.15, 0.15, 0.15).into(),
|
||||
..Default::default()
|
||||
});
|
||||
})
|
||||
.with_children(|parent| {
|
||||
// Title
|
||||
parent.spawn_bundle(TextBundle {
|
||||
style: Style {
|
||||
size: Size::new(Val::Undefined, Val::Px(25.)),
|
||||
margin: Rect {
|
||||
left: Val::Auto,
|
||||
right: Val::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
text: Text::with_section(
|
||||
"Scrolling list",
|
||||
TextStyle {
|
||||
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
|
||||
font_size: 25.,
|
||||
color: Color::WHITE,
|
||||
},
|
||||
Default::default(),
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
// List with hidden overflow
|
||||
parent
|
||||
.spawn_bundle(NodeBundle {
|
||||
style: Style {
|
||||
flex_direction: FlexDirection::ColumnReverse,
|
||||
align_self: AlignSelf::Center,
|
||||
size: Size::new(Val::Percent(100.0), Val::Percent(50.0)),
|
||||
overflow: Overflow::Hidden,
|
||||
..Default::default()
|
||||
},
|
||||
color: Color::rgb(0.10, 0.10, 0.10).into(),
|
||||
..Default::default()
|
||||
})
|
||||
.with_children(|parent| {
|
||||
// Moving panel
|
||||
parent
|
||||
.spawn_bundle(NodeBundle {
|
||||
style: Style {
|
||||
flex_direction: FlexDirection::ColumnReverse,
|
||||
flex_grow: 1.0,
|
||||
max_size: Size::new(Val::Undefined, Val::Undefined),
|
||||
..Default::default()
|
||||
},
|
||||
color: Color::NONE.into(),
|
||||
..Default::default()
|
||||
})
|
||||
.insert(ScrollingList::default())
|
||||
.with_children(|parent| {
|
||||
// List items
|
||||
for i in 0..30 {
|
||||
parent.spawn_bundle(TextBundle {
|
||||
style: Style {
|
||||
flex_shrink: 0.,
|
||||
size: Size::new(Val::Undefined, Val::Px(20.)),
|
||||
margin: Rect {
|
||||
left: Val::Auto,
|
||||
right: Val::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
text: Text::with_section(
|
||||
format!("Item {}", i),
|
||||
TextStyle {
|
||||
font: asset_server
|
||||
.load("fonts/FiraSans-Bold.ttf"),
|
||||
font_size: 20.,
|
||||
color: Color::WHITE,
|
||||
},
|
||||
Default::default(),
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// absolute positioning
|
||||
parent
|
||||
.spawn_bundle(NodeBundle {
|
||||
@ -212,3 +299,32 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Component, Default)]
|
||||
struct ScrollingList {
|
||||
position: f32,
|
||||
}
|
||||
|
||||
fn mouse_scroll(
|
||||
mut mouse_wheel_events: EventReader<MouseWheel>,
|
||||
mut query_list: Query<(&mut ScrollingList, &mut Style, &Children, &Node)>,
|
||||
query_item: Query<&Node>,
|
||||
) {
|
||||
for mouse_wheel_event in mouse_wheel_events.iter() {
|
||||
for (mut scrolling_list, mut style, children, uinode) in query_list.iter_mut() {
|
||||
let items_height: f32 = children
|
||||
.iter()
|
||||
.map(|entity| query_item.get(*entity).unwrap().size.y)
|
||||
.sum();
|
||||
let panel_height = uinode.size.y;
|
||||
let max_scroll = (items_height - panel_height).max(0.);
|
||||
let dy = match mouse_wheel_event.unit {
|
||||
MouseScrollUnit::Line => mouse_wheel_event.y * 20.,
|
||||
MouseScrollUnit::Pixel => mouse_wheel_event.y,
|
||||
};
|
||||
scrolling_list.position += dy;
|
||||
scrolling_list.position = scrolling_list.position.clamp(-max_scroll, 0.);
|
||||
style.position.top = Val::Px(scrolling_list.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user