mirror of https://github.com/hannobraun/Fornjot
Merge pull request #1823 from hannobraun/window
Move `fj-window` to `fornjot-extra`
This commit is contained in:
commit
6ea77938d2
11
Cargo.toml
11
Cargo.toml
|
@ -6,7 +6,6 @@ members = [
|
||||||
"crates/fj-kernel",
|
"crates/fj-kernel",
|
||||||
"crates/fj-math",
|
"crates/fj-math",
|
||||||
"crates/fj-viewer",
|
"crates/fj-viewer",
|
||||||
# "crates/fj-window",
|
|
||||||
|
|
||||||
"tools/autolib",
|
"tools/autolib",
|
||||||
"tools/automator",
|
"tools/automator",
|
||||||
|
@ -20,7 +19,6 @@ default-members = [
|
||||||
"crates/fj-kernel",
|
"crates/fj-kernel",
|
||||||
"crates/fj-math",
|
"crates/fj-math",
|
||||||
"crates/fj-viewer",
|
"crates/fj-viewer",
|
||||||
# "crates/fj-window",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -28,10 +26,7 @@ default-members = [
|
||||||
version = "0.46.0"
|
version = "0.46.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
description = """\
|
description = "Early-stage b-rep CAD kernel."
|
||||||
Early-stage, next-generation, code-first CAD application. Because the world \
|
|
||||||
needs another CAD program.\
|
|
||||||
"""
|
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
homepage = "https://www.fornjot.app/"
|
homepage = "https://www.fornjot.app/"
|
||||||
repository = "https://github.com/hannobraun/fornjot"
|
repository = "https://github.com/hannobraun/fornjot"
|
||||||
|
@ -75,7 +70,3 @@ path = "crates/fj-proc"
|
||||||
[workspace.dependencies.fj-viewer]
|
[workspace.dependencies.fj-viewer]
|
||||||
version = "0.46.0"
|
version = "0.46.0"
|
||||||
path = "crates/fj-viewer"
|
path = "crates/fj-viewer"
|
||||||
|
|
||||||
[workspace.dependencies.fj-window]
|
|
||||||
version = "0.46.0"
|
|
||||||
path = "crates/fj-window"
|
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "fj-window"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
description.workspace = true
|
|
||||||
readme.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
keywords.workspace = true
|
|
||||||
categories.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
fj-host.workspace = true
|
|
||||||
fj-operations.workspace = true
|
|
||||||
fj-viewer.workspace = true
|
|
||||||
fj-interop.workspace = true
|
|
||||||
crossbeam-channel = "0.5.8"
|
|
||||||
futures = "0.3.28"
|
|
||||||
thiserror = "1.0.40"
|
|
||||||
tracing = "0.1.37"
|
|
||||||
winit = "0.28.5"
|
|
||||||
|
|
||||||
[dependencies.egui-winit]
|
|
||||||
version = "0.21.1"
|
|
||||||
default-features = false
|
|
|
@ -1,290 +0,0 @@
|
||||||
use fj_host::{Host, Model, ModelEvent, Parameters};
|
|
||||||
use fj_operations::shape_processor;
|
|
||||||
use fj_viewer::{
|
|
||||||
GuiState, InputEvent, NormalizedScreenPosition, Screen, ScreenSize,
|
|
||||||
StatusReport, Viewer,
|
|
||||||
};
|
|
||||||
use winit::{
|
|
||||||
dpi::PhysicalPosition,
|
|
||||||
event::{
|
|
||||||
ElementState, Event, KeyboardInput, MouseButton, MouseScrollDelta,
|
|
||||||
VirtualKeyCode, WindowEvent,
|
|
||||||
},
|
|
||||||
event_loop::ControlFlow,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::window::Window;
|
|
||||||
|
|
||||||
pub struct EventLoopHandler {
|
|
||||||
pub invert_zoom: bool,
|
|
||||||
pub window: Window,
|
|
||||||
pub viewer: Viewer,
|
|
||||||
pub egui_winit_state: egui_winit::State,
|
|
||||||
pub host: Host,
|
|
||||||
pub status: StatusReport,
|
|
||||||
pub held_mouse_button: Option<MouseButton>,
|
|
||||||
|
|
||||||
/// Only handle resize events once every frame. This filters out spurious
|
|
||||||
/// resize events that can lead to wgpu warnings. See this issue for some
|
|
||||||
/// context:
|
|
||||||
/// <https://github.com/rust-windowing/winit/issues/2094>
|
|
||||||
pub new_size: Option<ScreenSize>,
|
|
||||||
pub stop_drawing: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventLoopHandler {
|
|
||||||
pub fn handle_event(
|
|
||||||
&mut self,
|
|
||||||
event: Event<ModelEvent>,
|
|
||||||
control_flow: &mut ControlFlow,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
// Trigger a panic if the host thread has panicked.
|
|
||||||
self.host.propagate_panic();
|
|
||||||
|
|
||||||
if let Event::WindowEvent { event, .. } = &event {
|
|
||||||
let egui_winit::EventResponse {
|
|
||||||
consumed,
|
|
||||||
// This flag was introduced in egui-winit 0.20. I don't think we
|
|
||||||
// need to handle this, as we already do a full update of the
|
|
||||||
// GUI every frame. It might be possible to do less repainting
|
|
||||||
// though, if we only did it here, if the flag was set.
|
|
||||||
repaint: _,
|
|
||||||
} = self
|
|
||||||
.egui_winit_state
|
|
||||||
.on_event(self.viewer.gui.context(), event);
|
|
||||||
|
|
||||||
if consumed {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let input_event = input_event(
|
|
||||||
&event,
|
|
||||||
&self.window,
|
|
||||||
&self.held_mouse_button,
|
|
||||||
&mut self.viewer.cursor,
|
|
||||||
self.invert_zoom,
|
|
||||||
);
|
|
||||||
if let Some(input_event) = input_event {
|
|
||||||
self.viewer.handle_input_event(input_event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// fj-window events
|
|
||||||
match event {
|
|
||||||
Event::UserEvent(event) => match event {
|
|
||||||
ModelEvent::StartWatching => {
|
|
||||||
self.status
|
|
||||||
.update_status("New model loaded. Evaluating model...");
|
|
||||||
}
|
|
||||||
ModelEvent::ChangeDetected => {
|
|
||||||
self.status.update_status(
|
|
||||||
"Change in model detected. Evaluating model...",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ModelEvent::Evaluated => {
|
|
||||||
self.status
|
|
||||||
.update_status("Model evaluated. Processing model...");
|
|
||||||
}
|
|
||||||
ModelEvent::ProcessedShape(shape) => {
|
|
||||||
self.viewer.handle_shape_update(shape);
|
|
||||||
self.status.update_status("Model processed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
ModelEvent::Error(err) => {
|
|
||||||
return Err(Box::new(err).into());
|
|
||||||
}
|
|
||||||
ModelEvent::Warning(warning) => {
|
|
||||||
self.status.update_status(&warning);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Event::WindowEvent {
|
|
||||||
event: WindowEvent::CloseRequested,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
*control_flow = ControlFlow::Exit;
|
|
||||||
}
|
|
||||||
Event::WindowEvent {
|
|
||||||
event:
|
|
||||||
WindowEvent::KeyboardInput {
|
|
||||||
input:
|
|
||||||
KeyboardInput {
|
|
||||||
state: ElementState::Pressed,
|
|
||||||
virtual_keycode: Some(virtual_key_code),
|
|
||||||
..
|
|
||||||
},
|
|
||||||
..
|
|
||||||
},
|
|
||||||
..
|
|
||||||
} => match virtual_key_code {
|
|
||||||
VirtualKeyCode::Escape => *control_flow = ControlFlow::Exit,
|
|
||||||
VirtualKeyCode::Key1 => {
|
|
||||||
self.viewer.toggle_draw_model();
|
|
||||||
}
|
|
||||||
VirtualKeyCode::Key2 => {
|
|
||||||
self.viewer.toggle_draw_mesh();
|
|
||||||
}
|
|
||||||
VirtualKeyCode::Key3 => {
|
|
||||||
self.viewer.toggle_draw_debug();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Event::WindowEvent {
|
|
||||||
event: WindowEvent::Resized(size),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
self.new_size = Some(ScreenSize {
|
|
||||||
width: size.width,
|
|
||||||
height: size.height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Event::WindowEvent {
|
|
||||||
event: WindowEvent::MouseInput { state, button, .. },
|
|
||||||
..
|
|
||||||
} => match state {
|
|
||||||
ElementState::Pressed => {
|
|
||||||
self.held_mouse_button = Some(button);
|
|
||||||
self.viewer.add_focus_point();
|
|
||||||
}
|
|
||||||
ElementState::Released => {
|
|
||||||
self.held_mouse_button = None;
|
|
||||||
self.viewer.remove_focus_point();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Event::WindowEvent {
|
|
||||||
event: WindowEvent::MouseWheel { .. },
|
|
||||||
..
|
|
||||||
} => self.viewer.add_focus_point(),
|
|
||||||
Event::MainEventsCleared => {
|
|
||||||
self.window.window().request_redraw();
|
|
||||||
}
|
|
||||||
Event::RedrawRequested(_) => {
|
|
||||||
// Only do a screen resize once per frame. This protects against
|
|
||||||
// spurious resize events that cause issues with the renderer.
|
|
||||||
if let Some(size) = self.new_size.take() {
|
|
||||||
self.stop_drawing = size.width == 0 || size.height == 0;
|
|
||||||
if !self.stop_drawing {
|
|
||||||
self.viewer.handle_screen_resize(size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.stop_drawing {
|
|
||||||
let pixels_per_point =
|
|
||||||
self.window.window().scale_factor() as f32;
|
|
||||||
|
|
||||||
self.egui_winit_state
|
|
||||||
.set_pixels_per_point(pixels_per_point);
|
|
||||||
let egui_input = self
|
|
||||||
.egui_winit_state
|
|
||||||
.take_egui_input(self.window.window());
|
|
||||||
|
|
||||||
let gui_state = GuiState {
|
|
||||||
status: &self.status,
|
|
||||||
model_available: self.host.is_model_loaded(),
|
|
||||||
};
|
|
||||||
let new_model_path = self.viewer.draw(
|
|
||||||
pixels_per_point,
|
|
||||||
egui_input,
|
|
||||||
gui_state,
|
|
||||||
);
|
|
||||||
if let Some(model_path) = new_model_path {
|
|
||||||
let model = Model::new(model_path, Parameters::empty())
|
|
||||||
.map_err(Box::new)?;
|
|
||||||
self.host.load_model(model);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn input_event<T>(
|
|
||||||
event: &Event<T>,
|
|
||||||
window: &Window,
|
|
||||||
held_mouse_button: &Option<MouseButton>,
|
|
||||||
previous_cursor: &mut Option<NormalizedScreenPosition>,
|
|
||||||
invert_zoom: bool,
|
|
||||||
) -> Option<InputEvent> {
|
|
||||||
match event {
|
|
||||||
Event::WindowEvent {
|
|
||||||
event: WindowEvent::CursorMoved { position, .. },
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let [width, height] = window.size().as_f64();
|
|
||||||
let aspect_ratio = width / height;
|
|
||||||
|
|
||||||
// Cursor position in normalized coordinates (-1 to +1) with
|
|
||||||
// aspect ratio taken into account.
|
|
||||||
let current = NormalizedScreenPosition {
|
|
||||||
x: position.x / width * 2. - 1.,
|
|
||||||
y: -(position.y / height * 2. - 1.) / aspect_ratio,
|
|
||||||
};
|
|
||||||
let event = match (*previous_cursor, held_mouse_button) {
|
|
||||||
(Some(previous), Some(button)) => match button {
|
|
||||||
MouseButton::Left => {
|
|
||||||
let diff_x = current.x - previous.x;
|
|
||||||
let diff_y = current.y - previous.y;
|
|
||||||
let angle_x = -diff_y * ROTATION_SENSITIVITY;
|
|
||||||
let angle_y = diff_x * ROTATION_SENSITIVITY;
|
|
||||||
|
|
||||||
Some(InputEvent::Rotation { angle_x, angle_y })
|
|
||||||
}
|
|
||||||
MouseButton::Right => {
|
|
||||||
Some(InputEvent::Translation { previous, current })
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
*previous_cursor = Some(current);
|
|
||||||
event
|
|
||||||
}
|
|
||||||
Event::WindowEvent {
|
|
||||||
event: WindowEvent::MouseWheel { delta, .. },
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let delta = match delta {
|
|
||||||
MouseScrollDelta::LineDelta(_, y) => {
|
|
||||||
f64::from(*y) * ZOOM_FACTOR_LINE
|
|
||||||
}
|
|
||||||
MouseScrollDelta::PixelDelta(PhysicalPosition {
|
|
||||||
y, ..
|
|
||||||
}) => y * ZOOM_FACTOR_PIXEL,
|
|
||||||
};
|
|
||||||
|
|
||||||
let delta = if invert_zoom { -delta } else { delta };
|
|
||||||
|
|
||||||
Some(InputEvent::Zoom(delta))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum Error {
|
|
||||||
#[error("Host error")]
|
|
||||||
Host(#[from] Box<fj_host::Error>),
|
|
||||||
|
|
||||||
#[error("Shape processing error")]
|
|
||||||
ShapeProcessor(#[from] Box<shape_processor::Error>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Affects the speed of zoom movement given a scroll wheel input in lines.
|
|
||||||
///
|
|
||||||
/// Smaller values will move the camera less with the same input.
|
|
||||||
/// Larger values will move the camera more with the same input.
|
|
||||||
const ZOOM_FACTOR_LINE: f64 = 0.075;
|
|
||||||
|
|
||||||
/// Affects the speed of zoom movement given a scroll wheel input in pixels.
|
|
||||||
///
|
|
||||||
/// Smaller values will move the camera less with the same input.
|
|
||||||
/// Larger values will move the camera more with the same input.
|
|
||||||
const ZOOM_FACTOR_PIXEL: f64 = 0.005;
|
|
||||||
|
|
||||||
/// Affects the speed of rotation given a change in normalized screen position [-1, 1]
|
|
||||||
///
|
|
||||||
/// Smaller values will move the camera less with the same input.
|
|
||||||
/// Larger values will move the camera more with the same input.
|
|
||||||
const ROTATION_SENSITIVITY: f64 = 5.;
|
|
|
@ -1,19 +0,0 @@
|
||||||
//! # Fornjot Window Abstraction
|
|
||||||
//!
|
|
||||||
//! This library is part of the [Fornjot] ecosystem. Fornjot is an open-source,
|
|
||||||
//! code-first CAD application; and collection of libraries that make up the CAD
|
|
||||||
//! application, but can be used independently.
|
|
||||||
//!
|
|
||||||
//! This library is an internal component of Fornjot. It is not relevant to end
|
|
||||||
//! users that just want to create CAD models.
|
|
||||||
//!
|
|
||||||
//! This library provides a window abstraction based on Winit.
|
|
||||||
//!
|
|
||||||
//! [Fornjot]: https://www.fornjot.app/
|
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
|
||||||
|
|
||||||
pub mod run;
|
|
||||||
pub mod window;
|
|
||||||
|
|
||||||
mod event_loop_handler;
|
|
|
@ -1,117 +0,0 @@
|
||||||
//! Model viewer initialization and event processing
|
|
||||||
//!
|
|
||||||
//! Provides the functionality to create a window and perform basic viewing
|
|
||||||
//! with programmed models.
|
|
||||||
|
|
||||||
use std::{
|
|
||||||
error,
|
|
||||||
fmt::{self, Write},
|
|
||||||
thread,
|
|
||||||
};
|
|
||||||
|
|
||||||
use fj_host::{Host, Model, ModelEvent};
|
|
||||||
use fj_operations::shape_processor::ShapeProcessor;
|
|
||||||
use fj_viewer::{RendererInitError, StatusReport, Viewer};
|
|
||||||
use futures::executor::block_on;
|
|
||||||
use tracing::trace;
|
|
||||||
use winit::event_loop::EventLoopBuilder;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
event_loop_handler::{self, EventLoopHandler},
|
|
||||||
window::{self, Window},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Initializes a model viewer for a given model and enters its process loop.
|
|
||||||
pub fn run(
|
|
||||||
model: Option<Model>,
|
|
||||||
shape_processor: ShapeProcessor,
|
|
||||||
invert_zoom: bool,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let event_loop = EventLoopBuilder::<ModelEvent>::with_user_event().build();
|
|
||||||
let window = Window::new(&event_loop)?;
|
|
||||||
let viewer = block_on(Viewer::new(&window))?;
|
|
||||||
|
|
||||||
let egui_winit_state = egui_winit::State::new(&event_loop);
|
|
||||||
|
|
||||||
let (model_event_tx, model_event_rx) = crossbeam_channel::unbounded();
|
|
||||||
let event_proxy = event_loop.create_proxy();
|
|
||||||
|
|
||||||
let _event_relay_join_handle = thread::Builder::new()
|
|
||||||
.name("event_relay".to_string())
|
|
||||||
.spawn(move || {
|
|
||||||
for event in model_event_rx {
|
|
||||||
if event_proxy.send_event(event).is_err() {
|
|
||||||
// Looks like the main window closed.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut host = Host::new(shape_processor, model_event_tx);
|
|
||||||
|
|
||||||
if let Some(model) = model {
|
|
||||||
host.load_model(model);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut handler = EventLoopHandler {
|
|
||||||
invert_zoom,
|
|
||||||
window,
|
|
||||||
viewer,
|
|
||||||
egui_winit_state,
|
|
||||||
host,
|
|
||||||
status: StatusReport::new(),
|
|
||||||
held_mouse_button: None,
|
|
||||||
new_size: None,
|
|
||||||
stop_drawing: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
event_loop.run(move |event, _, control_flow| {
|
|
||||||
trace!("Handling event: {:?}", event);
|
|
||||||
|
|
||||||
if let Err(err) = handler.handle_event(event, control_flow) {
|
|
||||||
handle_error(err, &mut handler.status)
|
|
||||||
.expect("Expected error handling not to fail");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_error(
|
|
||||||
err: event_loop_handler::Error,
|
|
||||||
status: &mut StatusReport,
|
|
||||||
) -> Result<(), fmt::Error> {
|
|
||||||
// Can be cleaned up, once `Report` is stable:
|
|
||||||
// https://doc.rust-lang.org/std/error/struct.Report.html
|
|
||||||
|
|
||||||
let mut msg = String::new();
|
|
||||||
|
|
||||||
writeln!(msg, "Shape processing error: {err}")?;
|
|
||||||
|
|
||||||
let mut current_err = &err as &dyn error::Error;
|
|
||||||
while let Some(err) = current_err.source() {
|
|
||||||
writeln!(msg)?;
|
|
||||||
writeln!(msg, "Caused by:")?;
|
|
||||||
writeln!(msg, " {err}")?;
|
|
||||||
|
|
||||||
current_err = err;
|
|
||||||
}
|
|
||||||
|
|
||||||
status.update_status(&msg);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error in main loop
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum Error {
|
|
||||||
/// Error loading model
|
|
||||||
#[error("Error loading model")]
|
|
||||||
Model(#[from] fj_host::Error),
|
|
||||||
|
|
||||||
/// Error initializing window
|
|
||||||
#[error("Error initializing window")]
|
|
||||||
WindowInit(#[from] window::Error),
|
|
||||||
|
|
||||||
/// Error initializing graphics
|
|
||||||
#[error("Error initializing graphics")]
|
|
||||||
GraphicsInit(#[from] RendererInitError),
|
|
||||||
}
|
|
|
@ -1,43 +0,0 @@
|
||||||
//! CAD viewer utility windowing abstraction
|
|
||||||
|
|
||||||
use fj_viewer::{Screen, ScreenSize};
|
|
||||||
use winit::{event_loop::EventLoop, window::WindowBuilder};
|
|
||||||
|
|
||||||
/// Window abstraction providing details such as the width or height and easing initialization.
|
|
||||||
pub struct Window(winit::window::Window);
|
|
||||||
|
|
||||||
impl Window {
|
|
||||||
/// Returns a new window with the given `EventLoop`.
|
|
||||||
pub fn new<T>(event_loop: &EventLoop<T>) -> Result<Self, Error> {
|
|
||||||
let window = WindowBuilder::new()
|
|
||||||
.with_title("Fornjot")
|
|
||||||
.with_maximized(true)
|
|
||||||
.with_decorations(true)
|
|
||||||
.with_transparent(false)
|
|
||||||
.build(event_loop)?;
|
|
||||||
|
|
||||||
Ok(Self(window))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Screen for Window {
|
|
||||||
type Window = winit::window::Window;
|
|
||||||
|
|
||||||
fn size(&self) -> ScreenSize {
|
|
||||||
let size = self.0.inner_size();
|
|
||||||
|
|
||||||
ScreenSize {
|
|
||||||
width: size.width,
|
|
||||||
height: size.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn window(&self) -> &winit::window::Window {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error initializing window
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
#[error("Error initializing window")]
|
|
||||||
pub struct Error(#[from] pub winit::error::OsError);
|
|
Loading…
Reference in New Issue