mirror of
https://github.com/a-b-street/abstreet.git
synced 2024-11-25 22:13:27 +03:00
persistent split buttons for time increments. still need few tweaks, but
largely there
This commit is contained in:
parent
edffcf3db7
commit
8b6485b233
@ -12,6 +12,7 @@
|
||||
//! * [`Histogram`] - visualize a distribution
|
||||
//! * [`JustDraw`] (argh private) - just draw text, `GeomBatch`es, SVGs
|
||||
//! * [`Menu`] - select something from a menu, with keybindings
|
||||
//! * [`PersistentSplit`] - a button with a dropdown to change its state
|
||||
//! * [`Plot`] - visualize 2 variables with a line plot
|
||||
//! * [`Slider`] - horizontal and vertical sliders
|
||||
//! * [`Spinner`] - numeric input with up/down buttons
|
||||
@ -62,6 +63,7 @@ pub use crate::widgets::filler::Filler;
|
||||
pub use crate::widgets::histogram::Histogram;
|
||||
pub(crate) use crate::widgets::just_draw::JustDraw;
|
||||
pub(crate) use crate::widgets::menu::Menu;
|
||||
pub use crate::widgets::persistent_split::PersistentSplit;
|
||||
pub use crate::widgets::plot::{Plot, PlotOptions, Series};
|
||||
pub use crate::widgets::slider::Slider;
|
||||
pub use crate::widgets::spinner::Spinner;
|
||||
|
@ -1,8 +1,9 @@
|
||||
use crate::widgets::containers::{Container, Nothing};
|
||||
use crate::{
|
||||
Autocomplete, Btn, Button, Checkbox, Choice, Color, Drawable, Dropdown, EventCtx, Filler,
|
||||
GeomBatch, GfxCtx, HorizontalAlignment, JustDraw, Menu, MultiKey, RewriteColor, ScreenDims,
|
||||
ScreenPt, ScreenRectangle, Slider, Spinner, TextBox, VerticalAlignment, WidgetImpl,
|
||||
GeomBatch, GfxCtx, HorizontalAlignment, JustDraw, Menu, MultiKey, PersistentSplit,
|
||||
RewriteColor, ScreenDims, ScreenPt, ScreenRectangle, Slider, Spinner, TextBox,
|
||||
VerticalAlignment, WidgetImpl,
|
||||
};
|
||||
use geom::{Distance, Polygon};
|
||||
use std::collections::HashSet;
|
||||
@ -253,9 +254,15 @@ impl Widget {
|
||||
default_value: T,
|
||||
choices: Vec<Choice<T>>,
|
||||
) -> Widget {
|
||||
Widget::new(Box::new(Dropdown::new(ctx, label, default_value, choices)))
|
||||
.named(label)
|
||||
.outline(2.0, Color::WHITE)
|
||||
Widget::new(Box::new(Dropdown::new(
|
||||
ctx,
|
||||
label,
|
||||
default_value,
|
||||
choices,
|
||||
false,
|
||||
)))
|
||||
.named(label)
|
||||
.outline(2.0, Color::WHITE)
|
||||
}
|
||||
|
||||
pub fn row(widgets: Vec<Widget>) -> Widget {
|
||||
@ -709,6 +716,9 @@ impl Composite {
|
||||
pub fn dropdown_value<T: 'static + PartialEq + Clone>(&self, name: &str) -> T {
|
||||
self.find::<Dropdown<T>>(name).current_value()
|
||||
}
|
||||
pub fn persistent_split_value<T: 'static + PartialEq + Clone>(&self, name: &str) -> T {
|
||||
self.find::<PersistentSplit<T>>(name).current_value()
|
||||
}
|
||||
|
||||
pub fn autocomplete_done<T: 'static + Clone>(&self, name: &str) -> Option<Vec<T>> {
|
||||
self.find::<Autocomplete<T>>(name).final_value()
|
||||
|
@ -12,7 +12,7 @@ pub struct Button {
|
||||
draw_normal: Drawable,
|
||||
draw_hovered: Drawable,
|
||||
|
||||
hotkey: Option<MultiKey>,
|
||||
pub(crate) hotkey: Option<MultiKey>,
|
||||
tooltip: Text,
|
||||
// Screenspace, top-left always at the origin. Also, probably not a box. :P
|
||||
hitbox: Polygon,
|
||||
|
@ -1,8 +1,8 @@
|
||||
use crate::{
|
||||
Btn, Button, Choice, Color, EventCtx, GfxCtx, InputResult, Menu, Outcome, ScreenDims, ScreenPt,
|
||||
WidgetImpl,
|
||||
Btn, Button, Choice, Color, EventCtx, GeomBatch, GfxCtx, InputResult, Menu, Outcome,
|
||||
ScreenDims, ScreenPt, ScreenRectangle, WidgetImpl,
|
||||
};
|
||||
use geom::{Polygon, Pt2D};
|
||||
use geom::{Distance, Polygon, Pt2D};
|
||||
|
||||
pub struct Dropdown<T: Clone> {
|
||||
current_idx: usize,
|
||||
@ -10,6 +10,7 @@ pub struct Dropdown<T: Clone> {
|
||||
// TODO Why not T?
|
||||
menu: Option<Menu<usize>>,
|
||||
label: String,
|
||||
blank_btn_label: bool,
|
||||
|
||||
choices: Vec<Choice<T>>,
|
||||
}
|
||||
@ -20,6 +21,8 @@ impl<T: 'static + PartialEq + Clone> Dropdown<T> {
|
||||
label: &str,
|
||||
default_value: T,
|
||||
choices: Vec<Choice<T>>,
|
||||
// TODO Ideally builder style
|
||||
blank_btn_label: bool,
|
||||
) -> Dropdown<T> {
|
||||
let current_idx = choices
|
||||
.iter()
|
||||
@ -28,9 +31,10 @@ impl<T: 'static + PartialEq + Clone> Dropdown<T> {
|
||||
|
||||
Dropdown {
|
||||
current_idx,
|
||||
btn: make_btn(ctx, &choices[current_idx].label, label),
|
||||
btn: make_btn(ctx, &choices[current_idx].label, label, blank_btn_label),
|
||||
menu: None,
|
||||
label: label.to_string(),
|
||||
blank_btn_label,
|
||||
choices,
|
||||
}
|
||||
}
|
||||
@ -38,6 +42,9 @@ impl<T: 'static + PartialEq + Clone> Dropdown<T> {
|
||||
pub fn current_value(&self) -> T {
|
||||
self.choices[self.current_idx].data.clone()
|
||||
}
|
||||
pub(crate) fn current_value_label(&self) -> String {
|
||||
self.choices[self.current_idx].label.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: 'static + Clone> WidgetImpl for Dropdown<T> {
|
||||
@ -61,9 +68,12 @@ impl<T: 'static + Clone> WidgetImpl for Dropdown<T> {
|
||||
self.menu = None;
|
||||
self.current_idx = idx;
|
||||
let top_left = self.btn.top_left;
|
||||
// TODO Recalculate widgets when this happens... outline around button should
|
||||
// change
|
||||
self.btn = make_btn(ctx, &self.choices[self.current_idx].label, &self.label);
|
||||
self.btn = make_btn(
|
||||
ctx,
|
||||
&self.choices[self.current_idx].label,
|
||||
&self.label,
|
||||
self.blank_btn_label,
|
||||
);
|
||||
self.btn.set_pos(top_left);
|
||||
*redo_layout = true;
|
||||
}
|
||||
@ -80,9 +90,16 @@ impl<T: 'static + Clone> WidgetImpl for Dropdown<T> {
|
||||
.collect(),
|
||||
)
|
||||
.take_menu();
|
||||
let y1_below = self.btn.top_left.y + self.btn.dims.height + 15.0;
|
||||
|
||||
menu.set_pos(ScreenPt::new(
|
||||
self.btn.top_left.x,
|
||||
self.btn.top_left.y + self.btn.dims.height + 15.0,
|
||||
// top_left_for_corner doesn't quite work
|
||||
if y1_below + menu.get_dims().height < ctx.canvas.window_height {
|
||||
y1_below
|
||||
} else {
|
||||
self.btn.top_left.y - 15.0 - menu.get_dims().height
|
||||
},
|
||||
));
|
||||
self.menu = Some(menu);
|
||||
}
|
||||
@ -94,21 +111,41 @@ impl<T: 'static + Clone> WidgetImpl for Dropdown<T> {
|
||||
fn draw(&self, g: &mut GfxCtx) {
|
||||
self.btn.draw(g);
|
||||
if let Some(ref m) = self.menu {
|
||||
// We need a background too!
|
||||
g.fork(Pt2D::new(0.0, 0.0), m.top_left, 1.0, Some(0.1));
|
||||
g.draw_polygon(
|
||||
Color::grey(0.3),
|
||||
&Polygon::rounded_rectangle(m.get_dims().width, m.get_dims().height, Some(5.0)),
|
||||
// We need a background too! Add some padding and an outline.
|
||||
// TODO Little embedded Composite could make more sense?
|
||||
let pad = 5.0;
|
||||
let width = m.get_dims().width + 2.0 * pad;
|
||||
let height = m.get_dims().height + 2.0 * pad;
|
||||
let rect = Polygon::rounded_rectangle(width, height, Some(5.0));
|
||||
let draw_bg = g.upload(GeomBatch::from(vec![
|
||||
(Color::grey(0.3), rect.clone()),
|
||||
(Color::WHITE, rect.to_outline(Distance::meters(3.0))),
|
||||
]));
|
||||
g.fork(
|
||||
Pt2D::new(0.0, 0.0),
|
||||
ScreenPt::new(m.top_left.x - pad, m.top_left.y - pad),
|
||||
1.0,
|
||||
// Between SCREENSPACE_Z and TOOLTIP_Z
|
||||
Some(0.1),
|
||||
);
|
||||
g.redraw(&draw_bg);
|
||||
g.unfork();
|
||||
|
||||
m.draw(g);
|
||||
|
||||
// Dropdown menus often leak out of their Composite
|
||||
g.canvas
|
||||
.mark_covered_area(ScreenRectangle::top_left(m.top_left, m.get_dims()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_btn(ctx: &EventCtx, name: &str, label: &str) -> Button {
|
||||
Btn::text_fg(format!("{} ▼", name))
|
||||
.build(ctx, label, None)
|
||||
.take_btn()
|
||||
fn make_btn(ctx: &EventCtx, name: &str, label: &str, blank_btn_label: bool) -> Button {
|
||||
(if blank_btn_label {
|
||||
Btn::text_fg("▼")
|
||||
} else {
|
||||
Btn::text_fg(format!("{} ▼", name))
|
||||
})
|
||||
.build(ctx, label, None)
|
||||
.take_btn()
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ pub mod filler;
|
||||
pub mod histogram;
|
||||
pub mod just_draw;
|
||||
pub mod menu;
|
||||
pub mod persistent_split;
|
||||
pub mod plot;
|
||||
pub mod slider;
|
||||
pub mod spinner;
|
||||
|
75
ezgui/src/widgets/persistent_split.rs
Normal file
75
ezgui/src/widgets/persistent_split.rs
Normal file
@ -0,0 +1,75 @@
|
||||
use crate::{
|
||||
Btn, Button, Choice, Dropdown, EventCtx, GfxCtx, MultiKey, Outcome, ScreenDims, ScreenPt,
|
||||
Widget, WidgetImpl,
|
||||
};
|
||||
|
||||
pub struct PersistentSplit<T: Clone + PartialEq> {
|
||||
current_value: T,
|
||||
btn: Button,
|
||||
dropdown: Dropdown<T>,
|
||||
}
|
||||
|
||||
impl<T: 'static + PartialEq + Clone> PersistentSplit<T> {
|
||||
pub fn new(
|
||||
ctx: &EventCtx,
|
||||
label: &str,
|
||||
default_value: T,
|
||||
hotkey: Option<MultiKey>,
|
||||
choices: Vec<Choice<T>>,
|
||||
) -> Widget {
|
||||
let dropdown = Dropdown::new(ctx, "change", default_value, choices, true);
|
||||
let btn = Btn::plaintext(dropdown.current_value_label())
|
||||
.build(ctx, label, hotkey)
|
||||
.take_btn();
|
||||
|
||||
Widget::new(Box::new(PersistentSplit {
|
||||
current_value: dropdown.current_value(),
|
||||
btn,
|
||||
dropdown,
|
||||
}))
|
||||
.named(label)
|
||||
}
|
||||
|
||||
pub fn current_value(&self) -> T {
|
||||
self.current_value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: 'static + Clone + PartialEq> WidgetImpl for PersistentSplit<T> {
|
||||
fn get_dims(&self) -> ScreenDims {
|
||||
let dims1 = self.btn.get_dims();
|
||||
let dims2 = self.dropdown.get_dims();
|
||||
ScreenDims::new(dims1.width + dims2.width, dims1.height.max(dims2.height))
|
||||
}
|
||||
|
||||
fn set_pos(&mut self, top_left: ScreenPt) {
|
||||
self.btn.set_pos(top_left);
|
||||
self.dropdown
|
||||
.set_pos(ScreenPt::new(top_left.x + self.btn.dims.width, top_left.y));
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut EventCtx, redo_layout: &mut bool) -> Option<Outcome> {
|
||||
if let Some(o) = self.btn.event(ctx, redo_layout) {
|
||||
return Some(o);
|
||||
}
|
||||
|
||||
self.dropdown.event(ctx, redo_layout);
|
||||
let new_value = self.dropdown.current_value();
|
||||
if new_value != self.current_value {
|
||||
self.current_value = new_value;
|
||||
let hotkey = self.btn.hotkey.take();
|
||||
let label = self.btn.action.clone();
|
||||
self.btn = Btn::plaintext(self.dropdown.current_value_label())
|
||||
.build(ctx, label, hotkey)
|
||||
.take_btn();
|
||||
*redo_layout = true;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn draw(&self, g: &mut GfxCtx) {
|
||||
self.btn.draw(g);
|
||||
self.dropdown.draw(g);
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ use crate::game::{State, Transition};
|
||||
use ezgui::{
|
||||
hotkey, Btn, Choice, Composite, EventCtx, GfxCtx, Key, Line, Outcome, TextExt, Widget,
|
||||
};
|
||||
use geom::Duration;
|
||||
|
||||
// TODO SimOptions stuff too
|
||||
#[derive(Clone)]
|
||||
@ -10,6 +11,7 @@ pub struct Options {
|
||||
pub traffic_signal_style: TrafficSignalStyle,
|
||||
pub color_scheme: Option<String>,
|
||||
pub dev: bool,
|
||||
pub time_increment: Duration,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
@ -18,6 +20,7 @@ impl Options {
|
||||
traffic_signal_style: TrafficSignalStyle::GroupArrows,
|
||||
color_scheme: None,
|
||||
dev: false,
|
||||
time_increment: Duration::minutes(10),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1011,19 +1011,19 @@ impl TutorialState {
|
||||
)
|
||||
.msg(
|
||||
vec!["You can pause or resume time"],
|
||||
arrow(speed.composite.inner.center_of("pause")),
|
||||
arrow(speed.composite.center_of("pause")),
|
||||
)
|
||||
.msg(
|
||||
vec!["Speed things up"],
|
||||
arrow(speed.composite.inner.center_of("30x speed")),
|
||||
arrow(speed.composite.center_of("30x speed")),
|
||||
)
|
||||
.msg(
|
||||
vec!["Advance time by certain amounts"],
|
||||
arrow(speed.composite.inner.center_of("step forwards 1 hour")),
|
||||
arrow(speed.composite.center_of("step forwards")),
|
||||
)
|
||||
.msg(
|
||||
vec!["And reset to the beginning of the day"],
|
||||
arrow(speed.composite.inner.center_of("reset to midnight")),
|
||||
arrow(speed.composite.center_of("reset to midnight")),
|
||||
)
|
||||
.msg(
|
||||
vec!["Let's try these controls out. Run the simulation until 5pm or later."],
|
||||
@ -1042,7 +1042,7 @@ impl TutorialState {
|
||||
"You might've figured it out already,",
|
||||
"But you'll be pausing/resuming time VERY frequently",
|
||||
],
|
||||
arrow(speed.composite.inner.center_of("pause")),
|
||||
arrow(speed.composite.center_of("pause")),
|
||||
)
|
||||
.msg(
|
||||
vec![
|
||||
@ -1102,7 +1102,7 @@ impl TutorialState {
|
||||
"You don't have to manually chase them; just click to follow.",
|
||||
"(If you do lose track of them, just reset)",
|
||||
],
|
||||
arrow(speed.composite.inner.center_of("reset to midnight")),
|
||||
arrow(speed.composite.center_of("reset to midnight")),
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -2,18 +2,17 @@ use crate::app::App;
|
||||
use crate::common::{Overlays, Warping};
|
||||
use crate::game::{msg, State, Transition};
|
||||
use crate::helpers::ID;
|
||||
use crate::managed::{WrappedComposite, WrappedOutcome};
|
||||
use crate::sandbox::{GameplayMode, SandboxMode};
|
||||
use ezgui::{
|
||||
hotkey, Btn, Color, Composite, EventCtx, EventLoopMode, GeomBatch, GfxCtx, HorizontalAlignment,
|
||||
Key, Line, Outcome, Plot, PlotOptions, RewriteColor, Series, Slider, Text, VerticalAlignment,
|
||||
Widget,
|
||||
hotkey, Btn, Choice, Color, Composite, EventCtx, EventLoopMode, GeomBatch, GfxCtx,
|
||||
HorizontalAlignment, Key, Line, Outcome, PersistentSplit, Plot, PlotOptions, RewriteColor,
|
||||
Series, Slider, Text, VerticalAlignment, Widget,
|
||||
};
|
||||
use geom::{Duration, Polygon, Time};
|
||||
use instant::Instant;
|
||||
|
||||
pub struct SpeedControls {
|
||||
pub composite: WrappedComposite,
|
||||
pub composite: Composite,
|
||||
|
||||
paused: bool,
|
||||
setting: SpeedSetting,
|
||||
@ -33,12 +32,7 @@ enum SpeedSetting {
|
||||
|
||||
impl SpeedControls {
|
||||
// TODO Could use custom_checkbox here, but not sure it'll make things that much simpler.
|
||||
fn make_panel(
|
||||
ctx: &mut EventCtx,
|
||||
app: &App,
|
||||
paused: bool,
|
||||
setting: SpeedSetting,
|
||||
) -> WrappedComposite {
|
||||
fn make_panel(ctx: &mut EventCtx, app: &App, paused: bool, setting: SpeedSetting) -> Composite {
|
||||
let mut row = Vec::new();
|
||||
row.push(
|
||||
if paused {
|
||||
@ -87,26 +81,18 @@ impl SpeedControls {
|
||||
);
|
||||
|
||||
row.push(
|
||||
Widget::row(vec![
|
||||
Btn::custom(
|
||||
Text::from(Line("+1h").fg(Color::WHITE)).render_ctx(ctx),
|
||||
Text::from(Line("+1h").fg(app.cs.hovering)).render_ctx(ctx),
|
||||
{
|
||||
let dims = Text::from(Line("+1h")).render_ctx(ctx).get_dims();
|
||||
Polygon::rectangle(dims.width, dims.height)
|
||||
},
|
||||
)
|
||||
.build(ctx, "step forwards 1 hour", hotkey(Key::N)),
|
||||
Btn::custom(
|
||||
Text::from(Line("+0.1s").fg(Color::WHITE)).render_ctx(ctx),
|
||||
Text::from(Line("+0.1s").fg(app.cs.hovering)).render_ctx(ctx),
|
||||
{
|
||||
let dims = Text::from(Line("+0.1s")).render_ctx(ctx).get_dims();
|
||||
Polygon::rectangle(dims.width, dims.height)
|
||||
},
|
||||
)
|
||||
.build(ctx, "step forwards 0.1 seconds", hotkey(Key::M)),
|
||||
])
|
||||
PersistentSplit::new(
|
||||
ctx,
|
||||
"step forwards",
|
||||
app.opts.time_increment,
|
||||
hotkey(Key::M),
|
||||
vec![
|
||||
Choice::new("+1h", Duration::hours(1)),
|
||||
Choice::new("+30m", Duration::minutes(30)),
|
||||
Choice::new("+10m", Duration::minutes(10)),
|
||||
Choice::new("+0.1s", Duration::seconds(0.1)),
|
||||
],
|
||||
)
|
||||
.bg(app.cs.section_bg)
|
||||
.margin_right(16),
|
||||
);
|
||||
@ -123,38 +109,12 @@ impl SpeedControls {
|
||||
.bg(app.cs.section_bg),
|
||||
);
|
||||
|
||||
WrappedComposite::new(
|
||||
Composite::new(Widget::row(row).bg(app.cs.panel_bg).padding(16))
|
||||
.aligned(
|
||||
HorizontalAlignment::Center,
|
||||
VerticalAlignment::BottomAboveOSD,
|
||||
)
|
||||
.build(ctx),
|
||||
)
|
||||
.cb(
|
||||
"step forwards 0.1 seconds",
|
||||
Box::new(|ctx, app| {
|
||||
app.primary
|
||||
.sim
|
||||
.normal_step(&app.primary.map, Duration::seconds(0.1));
|
||||
if let Some(ref mut s) = app.secondary {
|
||||
s.sim.normal_step(&s.map, Duration::seconds(0.1));
|
||||
}
|
||||
app.recalculate_current_selection(ctx);
|
||||
None
|
||||
}),
|
||||
)
|
||||
.cb(
|
||||
"step forwards 1 hour",
|
||||
Box::new(|ctx, app| {
|
||||
Some(Transition::Push(Box::new(TimeWarpScreen::new(
|
||||
ctx,
|
||||
app,
|
||||
app.primary.sim.time() + Duration::hours(1),
|
||||
false,
|
||||
))))
|
||||
}),
|
||||
)
|
||||
Composite::new(Widget::row(row).bg(app.cs.panel_bg).padding(16))
|
||||
.aligned(
|
||||
HorizontalAlignment::Center,
|
||||
VerticalAlignment::BottomAboveOSD,
|
||||
)
|
||||
.build(ctx)
|
||||
}
|
||||
|
||||
pub fn new(ctx: &mut EventCtx, app: &App) -> SpeedControls {
|
||||
@ -172,11 +132,8 @@ impl SpeedControls {
|
||||
app: &mut App,
|
||||
maybe_mode: Option<&GameplayMode>,
|
||||
) -> Option<Transition> {
|
||||
match self.composite.event(ctx, app) {
|
||||
Some(WrappedOutcome::Transition(t)) => {
|
||||
return Some(t);
|
||||
}
|
||||
Some(WrappedOutcome::Clicked(x)) => match x.as_ref() {
|
||||
match self.composite.event(ctx) {
|
||||
Some(Outcome::Clicked(x)) => match x.as_ref() {
|
||||
"real-time speed" => {
|
||||
self.setting = SpeedSetting::Realtime;
|
||||
self.composite = SpeedControls::make_panel(ctx, app, self.paused, self.setting);
|
||||
@ -227,10 +184,29 @@ impl SpeedControls {
|
||||
maybe_mode.cloned(),
|
||||
))));
|
||||
}
|
||||
"step forwards" => {
|
||||
let dt = self.composite.persistent_split_value("step forwards");
|
||||
if dt == Duration::seconds(0.1) {
|
||||
app.primary.sim.normal_step(&app.primary.map, dt);
|
||||
if let Some(ref mut s) = app.secondary {
|
||||
s.sim.normal_step(&s.map, dt);
|
||||
}
|
||||
app.recalculate_current_selection(ctx);
|
||||
return None;
|
||||
}
|
||||
return Some(Transition::Push(Box::new(TimeWarpScreen::new(
|
||||
ctx,
|
||||
app,
|
||||
app.primary.sim.time() + dt,
|
||||
false,
|
||||
))));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
},
|
||||
None => {}
|
||||
}
|
||||
// Just kind of constantly scrape this
|
||||
app.opts.time_increment = self.composite.persistent_split_value("step forwards");
|
||||
|
||||
if ctx.input.new_was_pressed(&hotkey(Key::LeftArrow).unwrap()) {
|
||||
match self.setting {
|
||||
|
Loading…
Reference in New Issue
Block a user