First attempt at re-writing the transform editor
Still has issues with drag support, hover detection, but making progress
This commit is contained in:
parent
13ba4368aa
commit
0cddc9d9a1
10
Cargo.lock
generated
10
Cargo.lock
generated
@ -242,6 +242,15 @@ version = "1.0.95"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
|
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "approx"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ar"
|
name = "ar"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@ -1312,6 +1321,7 @@ name = "flare"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"approx",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
|
@ -180,6 +180,10 @@ impl Coefs {
|
|||||||
Self { a, b, c, d, e, f }
|
Self { a, b, c, d, e, f }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn new_from_xml_order(a: f32, d: f32, b: f32, e: f32, c: f32, f: f32) -> Self {
|
||||||
|
Self { a, b, c, d, e, f }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn apply(&self, point: glam::Vec2) -> glam::Vec2 {
|
pub fn apply(&self, point: glam::Vec2) -> glam::Vec2 {
|
||||||
vec2(
|
vec2(
|
||||||
self.a * point.x + self.b * point.y + self.c,
|
self.a * point.x + self.b * point.y + self.c,
|
||||||
|
@ -7,6 +7,7 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
approx = "0.5"
|
||||||
bytemuck.workspace = true
|
bytemuck.workspace = true
|
||||||
eframe = { version = "0.31", features = ["wgpu"]}
|
eframe = { version = "0.31", features = ["wgpu"]}
|
||||||
egui = "0.31"
|
egui = "0.31"
|
||||||
|
@ -1,17 +1,15 @@
|
|||||||
use flare::transform_editor::TransformEditor;
|
use egui::{Frame, Ui};
|
||||||
|
use flare::gui::transform_editor::TransformEditor;
|
||||||
use flare_shader::Coefs;
|
use flare_shader::Coefs;
|
||||||
use log::info;
|
use log::info;
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Debug)]
|
||||||
struct TransformEditorApp {
|
struct TransformEditorApp {
|
||||||
transform_editor: TransformEditor,
|
transform_editor: TransformEditor,
|
||||||
|
transforms: Vec<Coefs>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransformEditorApp {
|
impl TransformEditorApp {
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
transform_editor: TransformEditor::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for TransformEditorApp {
|
impl eframe::App for TransformEditorApp {
|
||||||
@ -24,13 +22,19 @@ impl eframe::App for TransformEditorApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if ui.button("Add Transform").clicked() {
|
if ui.button("Add Transform").clicked() {
|
||||||
self.transform_editor.add_transform()
|
self.transforms.push(Coefs::new(1.0, 0.0, 0.0, 0.0, 1.0, 0.0))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
egui::TopBottomPanel::bottom("bottom_panel").show(ctx, |ui| {
|
||||||
|
self.transform_editor.interact_debug(ui);
|
||||||
|
});
|
||||||
|
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
self.transform_editor.ui(ctx, ui)
|
Frame::canvas(ui.style()).show(ui, |ui| {
|
||||||
|
self.transform_editor.interact(ui, &mut self.transforms)
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -38,7 +42,6 @@ impl eframe::App for TransformEditorApp {
|
|||||||
fn main() -> eframe::Result {
|
fn main() -> eframe::Result {
|
||||||
std::env::set_var("RUST_LOG", "info");
|
std::env::set_var("RUST_LOG", "info");
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
info!("WTF");
|
|
||||||
|
|
||||||
let initial_dimensions = egui::vec2(800., 600.);
|
let initial_dimensions = egui::vec2(800., 600.);
|
||||||
let mut wgpu_options = egui_wgpu::WgpuConfiguration::default();
|
let mut wgpu_options = egui_wgpu::WgpuConfiguration::default();
|
||||||
@ -66,7 +69,7 @@ fn main() -> eframe::Result {
|
|||||||
"transform_editor",
|
"transform_editor",
|
||||||
native_options,
|
native_options,
|
||||||
Box::new(|cc| {
|
Box::new(|cc| {
|
||||||
Ok(Box::new(TransformEditorApp::new()))
|
Ok(Box::new(TransformEditorApp::default()))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
1
crates/flare/src/gui/mod.rs
Normal file
1
crates/flare/src/gui/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod transform_editor;
|
355
crates/flare/src/gui/transform_editor.rs
Normal file
355
crates/flare/src/gui/transform_editor.rs
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
use egui::emath::RectTransform;
|
||||||
|
use egui::*;
|
||||||
|
use flare_shader::Coefs;
|
||||||
|
|
||||||
|
const HANDLE_RADIUS_IFS: f32 = 0.04;
|
||||||
|
const HANDLE_RADIUS_DRAW_IFS: f32 = HANDLE_RADIUS_IFS / 2.0;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||||
|
pub enum TransformElement {
|
||||||
|
Origin,
|
||||||
|
X,
|
||||||
|
Y,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affine coefficients expressed as three points of a triangle
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||||
|
pub struct CoefsTriangle {
|
||||||
|
origin: Pos2,
|
||||||
|
x: Pos2,
|
||||||
|
y: Pos2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoefsTriangle {
|
||||||
|
pub fn new(origin: Pos2, x: Pos2, y: Pos2) -> Self {
|
||||||
|
Self { origin, x, y }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Coefs> for CoefsTriangle {
|
||||||
|
fn from(value: Coefs) -> Self {
|
||||||
|
let origin = pos2(value.c, -value.f);
|
||||||
|
Self {
|
||||||
|
origin,
|
||||||
|
x: origin + vec2(value.a, -value.d),
|
||||||
|
y: origin + vec2(-value.b, value.e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<Coefs> for CoefsTriangle {
|
||||||
|
fn into(self) -> Coefs {
|
||||||
|
Coefs {
|
||||||
|
a: self.x.x - self.origin.x,
|
||||||
|
b: self.origin.x - self.y.x,
|
||||||
|
c: self.origin.x,
|
||||||
|
d: self.origin.y - self.x.y,
|
||||||
|
e: self.y.y - self.origin.y,
|
||||||
|
f: -self.origin.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget for manipulating IFS transform affine coefficients
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct TransformEditor {
|
||||||
|
/// Center point (in IFS coordinates) of the editor window
|
||||||
|
center_ifs: Pos2,
|
||||||
|
|
||||||
|
/// Total range (in IFS coordinates) of the editor window
|
||||||
|
range_ifs: f32,
|
||||||
|
|
||||||
|
/// Hover position (in IFS coordinates) of the cursor.
|
||||||
|
///
|
||||||
|
/// Because of input latency during large movements, `egui`'s drag motion
|
||||||
|
/// doesn't precisely match the prior cursor position. Track the position
|
||||||
|
/// here to calculate an exact update from the last frame to now.
|
||||||
|
hover_pos_ifs: Option<Pos2>,
|
||||||
|
|
||||||
|
/// Index of the transform the cursor is hovering over
|
||||||
|
hover_index: Option<usize>,
|
||||||
|
|
||||||
|
/// For the hovered transform, which specific element the cursor is hovering over.
|
||||||
|
/// Assumed to always have a value when `hover_index` has a value
|
||||||
|
hover_element: Option<TransformElement>,
|
||||||
|
|
||||||
|
/// Index of the transform being dragged
|
||||||
|
drag_index: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TransformEditor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
center_ifs: Pos2::ZERO,
|
||||||
|
range_ifs: 4.0,
|
||||||
|
hover_pos_ifs: None,
|
||||||
|
hover_index: None,
|
||||||
|
hover_element: None,
|
||||||
|
drag_index: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_in_circle(pt: Pos2, center: Pos2, radius: f32) -> bool {
|
||||||
|
((pt.x - center.x).powf(2.0) + (pt.y - center.y).powf(2.0)) <= radius.powf(2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_in_triangle(pt: Pos2, v1: Pos2, v2: Pos2, v3: Pos2) -> bool {
|
||||||
|
// https://stackoverflow.com/a/2049593
|
||||||
|
let sign = |p1: Pos2, p2: Pos2, p3: Pos2| -> f32 {
|
||||||
|
(p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y)
|
||||||
|
};
|
||||||
|
|
||||||
|
let d1 = sign(pt, v1, v2);
|
||||||
|
let d2 = sign(pt, v2, v3);
|
||||||
|
let d3 = sign(pt, v3, v1);
|
||||||
|
|
||||||
|
let has_neg = [d1, d2, d3].iter().any(|v| *v < 0.0);
|
||||||
|
let has_pos = [d1, d2, d3].iter().any(|v| *v > 0.0);
|
||||||
|
|
||||||
|
!(has_neg && has_pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test whether the provided position is hovering on the transform
|
||||||
|
fn test_hovered(hover_pos: Option<Pos2>, coefs: Coefs) -> Option<TransformElement> {
|
||||||
|
let coefs_triangle: CoefsTriangle = coefs.into();
|
||||||
|
|
||||||
|
if hover_pos.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hover_pos = hover_pos.unwrap();
|
||||||
|
if test_in_circle(hover_pos, coefs_triangle.x, HANDLE_RADIUS_IFS) {
|
||||||
|
Some(TransformElement::X)
|
||||||
|
} else if test_in_circle(hover_pos, coefs_triangle.y, HANDLE_RADIUS_IFS) {
|
||||||
|
Some(TransformElement::Y)
|
||||||
|
} else if test_in_circle(hover_pos, coefs_triangle.origin, HANDLE_RADIUS_IFS)
|
||||||
|
|| test_in_triangle(
|
||||||
|
hover_pos,
|
||||||
|
coefs_triangle.origin,
|
||||||
|
coefs_triangle.x,
|
||||||
|
coefs_triangle.y,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Some(TransformElement::Origin)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_viewport_ifs(interact_rect: Rect, center_ifs: Pos2, range_ifs: f32) -> Rect {
|
||||||
|
let aspect_ratio = interact_rect.width() / interact_rect.height();
|
||||||
|
|
||||||
|
let size_ifs = if aspect_ratio >= 1.0 {
|
||||||
|
vec2(range_ifs * aspect_ratio, range_ifs)
|
||||||
|
} else {
|
||||||
|
vec2(range_ifs, range_ifs / aspect_ratio)
|
||||||
|
};
|
||||||
|
let min_ifs = center_ifs - size_ifs / 2.0;
|
||||||
|
let max_ifs = center_ifs + size_ifs / 2.0;
|
||||||
|
|
||||||
|
// IFS coordinates follow the screen coordinate "value increases from top left to bottom right"
|
||||||
|
// convention, so the Y-axis is flipped here to behave like a Cartesian plot
|
||||||
|
Rect::from_min_max(pos2(min_ifs.x, max_ifs.y), pos2(max_ifs.x, min_ifs.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransformEditor {
|
||||||
|
/// Interact with the provided transform coefficients. Returns the index of the focused transform, if any
|
||||||
|
pub fn interact(&mut self, ui: &mut Ui, transforms: &mut [Coefs]) -> Option<usize> {
|
||||||
|
let (response, painter) = ui.allocate_painter(ui.available_size(), Sense::drag());
|
||||||
|
|
||||||
|
if transforms.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let interact_rect = response.interact_rect;
|
||||||
|
let ifs_rect = build_viewport_ifs(interact_rect, self.center_ifs, self.range_ifs);
|
||||||
|
let pixels_per_unit_ifs = interact_rect.width() / ifs_rect.width();
|
||||||
|
|
||||||
|
// Update internal state based on screen interactions, then paint to screen
|
||||||
|
let to_ifs = RectTransform::from_to(interact_rect, ifs_rect);
|
||||||
|
let hover_pos_ifs = response.hover_pos().map(|p| to_ifs.transform_pos(p));
|
||||||
|
let focus_index = self.interact_update(
|
||||||
|
hover_pos_ifs,
|
||||||
|
response.clicked(),
|
||||||
|
response.drag_started(),
|
||||||
|
response.drag_stopped(),
|
||||||
|
transforms,
|
||||||
|
);
|
||||||
|
|
||||||
|
let to_screen = RectTransform::from_to(ifs_rect, interact_rect);
|
||||||
|
self.interact_draw(painter, to_screen, pixels_per_unit_ifs, transforms);
|
||||||
|
|
||||||
|
focus_index
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update state of the provided transform coefficients based on current interactions.
|
||||||
|
///
|
||||||
|
/// Assumes that positions/vectors are in IFS coordinates
|
||||||
|
fn interact_update(
|
||||||
|
&mut self,
|
||||||
|
hover_pos: Option<Pos2>,
|
||||||
|
clicked: bool,
|
||||||
|
drag_started: bool,
|
||||||
|
drag_stopped: bool,
|
||||||
|
transforms: &mut [Coefs],
|
||||||
|
) -> Option<usize> {
|
||||||
|
// Check each transform to see if it is hovered (giving priority to the currently hovered transform),
|
||||||
|
let mut hover_found = false;
|
||||||
|
if self.hover_index.is_some_and(|i| i < transforms.len()) {
|
||||||
|
let hover_element =
|
||||||
|
test_hovered(self.hover_pos_ifs, transforms[self.hover_index.unwrap()]);
|
||||||
|
if hover_element.is_some() {
|
||||||
|
hover_found = true;
|
||||||
|
self.hover_element = hover_element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hover_found {
|
||||||
|
for (i, transform) in transforms.iter().enumerate() {
|
||||||
|
if let Some(hover_element) = test_hovered(self.hover_pos_ifs, *transform) {
|
||||||
|
hover_found = true;
|
||||||
|
self.hover_index = Some(i);
|
||||||
|
self.hover_element = Some(hover_element);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hover_found {
|
||||||
|
// No transforms are hovered, clear interaction state and return
|
||||||
|
self.hover_pos_ifs = hover_pos;
|
||||||
|
self.hover_index = None;
|
||||||
|
self.hover_element = None;
|
||||||
|
self.drag_index = None;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hover_delta =
|
||||||
|
if let (Some(current_hover_pos), Some(hover_pos)) = (hover_pos, self.hover_pos_ifs) {
|
||||||
|
current_hover_pos - hover_pos
|
||||||
|
} else {
|
||||||
|
Vec2::ZERO
|
||||||
|
};
|
||||||
|
self.hover_pos_ifs = hover_pos;
|
||||||
|
|
||||||
|
// If the hovered transform is clicked, it receives focus and we end updates
|
||||||
|
if clicked {
|
||||||
|
return self.hover_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the hovered transform is dragged, it receives focus and the drag index is updated
|
||||||
|
if drag_started {
|
||||||
|
self.drag_index = self.hover_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there is a transform being dragged, update its position
|
||||||
|
if self.drag_index.is_some() && hover_delta.abs().max_elem() > 0.0 {
|
||||||
|
let mut coefs = &mut transforms[self.drag_index.unwrap()];
|
||||||
|
let mut coefs_triangle: CoefsTriangle = (*coefs).into();
|
||||||
|
|
||||||
|
match self.hover_element.unwrap() {
|
||||||
|
TransformElement::X => coefs_triangle.x += hover_delta,
|
||||||
|
TransformElement::Y => coefs_triangle.y += hover_delta,
|
||||||
|
TransformElement::Origin => {
|
||||||
|
coefs_triangle.x += hover_delta;
|
||||||
|
coefs_triangle.y += hover_delta;
|
||||||
|
coefs_triangle.origin += hover_delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*coefs = coefs_triangle.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the transform is no longer being dragged, clear the drag index
|
||||||
|
if drag_stopped {
|
||||||
|
self.drag_index = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.drag_index
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interact_draw_transform(
|
||||||
|
painter: &Painter,
|
||||||
|
to_screen: RectTransform,
|
||||||
|
pixels_per_unit_ifs: f32,
|
||||||
|
transform: Coefs,
|
||||||
|
hovered_element: Option<TransformElement>,
|
||||||
|
) {
|
||||||
|
let coefs_triangle: CoefsTriangle = transform.into();
|
||||||
|
let origin_screen = to_screen.transform_pos(coefs_triangle.origin);
|
||||||
|
let x_screen = to_screen.transform_pos(coefs_triangle.x);
|
||||||
|
let y_screen = to_screen.transform_pos(coefs_triangle.y);
|
||||||
|
|
||||||
|
let stroke = Stroke::new(2.0, Color32::BLUE);
|
||||||
|
painter.circle_stroke(
|
||||||
|
origin_screen,
|
||||||
|
HANDLE_RADIUS_DRAW_IFS * pixels_per_unit_ifs,
|
||||||
|
stroke,
|
||||||
|
);
|
||||||
|
painter.circle_stroke(
|
||||||
|
x_screen,
|
||||||
|
HANDLE_RADIUS_DRAW_IFS * pixels_per_unit_ifs,
|
||||||
|
stroke,
|
||||||
|
);
|
||||||
|
painter.circle_stroke(
|
||||||
|
y_screen,
|
||||||
|
HANDLE_RADIUS_DRAW_IFS * pixels_per_unit_ifs,
|
||||||
|
stroke,
|
||||||
|
);
|
||||||
|
|
||||||
|
let body_alpha: u8 = if hovered_element.is_some() { 8 } else { 0 };
|
||||||
|
let body_fill = Color32::from_rgba_unmultiplied(0, 0, u8::MAX, body_alpha);
|
||||||
|
let body = Shape::convex_polygon(
|
||||||
|
vec![origin_screen, x_screen, y_screen],
|
||||||
|
body_fill,
|
||||||
|
stroke,
|
||||||
|
);
|
||||||
|
painter.add(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the provided transform coefficients to the screen.
|
||||||
|
fn interact_draw(
|
||||||
|
&self,
|
||||||
|
painter: Painter,
|
||||||
|
to_screen: RectTransform,
|
||||||
|
pixels_per_unit_ifs: f32,
|
||||||
|
transforms: &[Coefs],
|
||||||
|
) {
|
||||||
|
// Hovered transform is painted at the end so it has priority
|
||||||
|
transforms
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(i, _)| Some(*i) != self.hover_index)
|
||||||
|
.for_each(|(_, transform)| {
|
||||||
|
TransformEditor::interact_draw_transform(
|
||||||
|
&painter,
|
||||||
|
to_screen,
|
||||||
|
pixels_per_unit_ifs,
|
||||||
|
*transform,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
self.hover_index.map(|i| {
|
||||||
|
TransformEditor::interact_draw_transform(
|
||||||
|
&painter,
|
||||||
|
to_screen,
|
||||||
|
pixels_per_unit_ifs,
|
||||||
|
transforms[i],
|
||||||
|
self.hover_element,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn interact_debug(&self, ui: &mut Ui) {
|
||||||
|
Grid::new("transform_editor_debug")
|
||||||
|
.num_columns(3)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.label(format!("Hover index: {:?}", self.hover_index));
|
||||||
|
ui.label(format!("Hover element: {:?}", self.hover_element));
|
||||||
|
ui.label(format!("Drag index: {:?}", self.drag_index));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1 +1,2 @@
|
|||||||
pub mod transform_editor;
|
pub mod transform_editor;
|
||||||
|
pub mod gui;
|
Loading…
Reference in New Issue
Block a user