mirror of
https://codeberg.org/waydeer/layer-shika.git
synced 2025-12-23 13:35:56 +00:00
refactor: extract popup builder and consolidate popup config
This commit is contained in:
parent
30c3100ca2
commit
0756fc0ef7
4 changed files with 343 additions and 33 deletions
|
|
@ -1,7 +1,9 @@
|
||||||
#![allow(clippy::pub_use)]
|
#![allow(clippy::pub_use)]
|
||||||
|
|
||||||
mod builder;
|
mod builder;
|
||||||
|
mod popup_builder;
|
||||||
mod system;
|
mod system;
|
||||||
|
mod value_conversion;
|
||||||
|
|
||||||
use layer_shika_adapters::errors::LayerShikaError;
|
use layer_shika_adapters::errors::LayerShikaError;
|
||||||
use layer_shika_domain::errors::DomainError;
|
use layer_shika_domain::errors::DomainError;
|
||||||
|
|
@ -21,6 +23,7 @@ pub use layer_shika_domain::value_objects::popup_positioning_mode::PopupPosition
|
||||||
pub use layer_shika_domain::value_objects::popup_request::{
|
pub use layer_shika_domain::value_objects::popup_request::{
|
||||||
PopupAt, PopupHandle, PopupRequest, PopupSize,
|
PopupAt, PopupHandle, PopupRequest, PopupSize,
|
||||||
};
|
};
|
||||||
|
pub use popup_builder::PopupBuilder;
|
||||||
pub use system::{App, EventLoopHandle, ShellContext, ShellControl};
|
pub use system::{App, EventLoopHandle, ShellContext, ShellControl};
|
||||||
|
|
||||||
pub mod calloop {
|
pub mod calloop {
|
||||||
|
|
@ -47,8 +50,8 @@ pub mod prelude {
|
||||||
pub use crate::{
|
pub use crate::{
|
||||||
AnchorEdges, App, EventLoopHandle, KeyboardInteractivity, Layer, LayerShika,
|
AnchorEdges, App, EventLoopHandle, KeyboardInteractivity, Layer, LayerShika,
|
||||||
OutputGeometry, OutputHandle, OutputInfo, OutputPolicy, OutputRegistry, PopupAt,
|
OutputGeometry, OutputHandle, OutputInfo, OutputPolicy, OutputRegistry, PopupAt,
|
||||||
PopupHandle, PopupPositioningMode, PopupRequest, PopupSize, PopupWindow, Result,
|
PopupBuilder, PopupHandle, PopupPositioningMode, PopupRequest, PopupSize, PopupWindow,
|
||||||
ShellContext, ShellControl,
|
Result, ShellContext, ShellControl,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use crate::calloop::{Generic, Interest, Mode, PostAction, RegistrationToken, Timer};
|
pub use crate::calloop::{Generic, Interest, Mode, PostAction, RegistrationToken, Timer};
|
||||||
|
|
|
||||||
216
crates/composition/src/popup_builder.rs
Normal file
216
crates/composition/src/popup_builder.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
use crate::Result;
|
||||||
|
use crate::system::App;
|
||||||
|
use layer_shika_adapters::platform::slint_interpreter::Value;
|
||||||
|
use layer_shika_domain::value_objects::popup_positioning_mode::PopupPositioningMode;
|
||||||
|
use layer_shika_domain::value_objects::popup_request::{PopupAt, PopupRequest, PopupSize};
|
||||||
|
|
||||||
|
pub struct PopupBuilder<'a> {
|
||||||
|
app: &'a App,
|
||||||
|
component: String,
|
||||||
|
reference: PopupAt,
|
||||||
|
anchor: PopupPositioningMode,
|
||||||
|
size: PopupSize,
|
||||||
|
grab: bool,
|
||||||
|
close_callback: Option<String>,
|
||||||
|
resize_callback: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> PopupBuilder<'a> {
|
||||||
|
pub(crate) fn new(app: &'a App, component: String) -> Self {
|
||||||
|
Self {
|
||||||
|
app,
|
||||||
|
component,
|
||||||
|
reference: PopupAt::Cursor,
|
||||||
|
anchor: PopupPositioningMode::TopLeft,
|
||||||
|
size: PopupSize::Content,
|
||||||
|
grab: false,
|
||||||
|
close_callback: None,
|
||||||
|
resize_callback: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn relative_to_cursor(mut self) -> Self {
|
||||||
|
self.reference = PopupAt::Cursor;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn relative_to_point(mut self, x: f32, y: f32) -> Self {
|
||||||
|
self.reference = PopupAt::Absolute { x, y };
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn relative_to_rect(mut self, x: f32, y: f32, w: f32, h: f32) -> Self {
|
||||||
|
self.reference = PopupAt::AnchorRect { x, y, w, h };
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn anchor(mut self, anchor: PopupPositioningMode) -> Self {
|
||||||
|
self.anchor = anchor;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn anchor_top_left(mut self) -> Self {
|
||||||
|
self.anchor = PopupPositioningMode::TopLeft;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn anchor_top_center(mut self) -> Self {
|
||||||
|
self.anchor = PopupPositioningMode::TopCenter;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn anchor_top_right(mut self) -> Self {
|
||||||
|
self.anchor = PopupPositioningMode::TopRight;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn anchor_center_left(mut self) -> Self {
|
||||||
|
self.anchor = PopupPositioningMode::CenterLeft;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn anchor_center(mut self) -> Self {
|
||||||
|
self.anchor = PopupPositioningMode::Center;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn anchor_center_right(mut self) -> Self {
|
||||||
|
self.anchor = PopupPositioningMode::CenterRight;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn anchor_bottom_left(mut self) -> Self {
|
||||||
|
self.anchor = PopupPositioningMode::BottomLeft;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn anchor_bottom_center(mut self) -> Self {
|
||||||
|
self.anchor = PopupPositioningMode::BottomCenter;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn anchor_bottom_right(mut self) -> Self {
|
||||||
|
self.anchor = PopupPositioningMode::BottomRight;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn size(mut self, size: PopupSize) -> Self {
|
||||||
|
self.size = size;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn fixed_size(mut self, w: f32, h: f32) -> Self {
|
||||||
|
self.size = PopupSize::Fixed { w, h };
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn content_size(mut self) -> Self {
|
||||||
|
self.size = PopupSize::Content;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn grab(mut self, enable: bool) -> Self {
|
||||||
|
self.grab = enable;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn close_on(mut self, callback_name: impl Into<String>) -> Self {
|
||||||
|
self.close_callback = Some(callback_name.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn resize_on(mut self, callback_name: impl Into<String>) -> Self {
|
||||||
|
self.resize_callback = Some(callback_name.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind(self, trigger_callback: &str) -> Result<()> {
|
||||||
|
let request = self.build_request();
|
||||||
|
let control = self.app.control();
|
||||||
|
|
||||||
|
self.app.with_all_component_instances(|instance| {
|
||||||
|
let request_clone = request.clone();
|
||||||
|
let control_clone = control.clone();
|
||||||
|
|
||||||
|
if let Err(e) = instance.set_callback(trigger_callback, move |_args| {
|
||||||
|
if let Err(e) = control_clone.show_popup(&request_clone) {
|
||||||
|
log::error!("Failed to show popup: {}", e);
|
||||||
|
}
|
||||||
|
Value::Void
|
||||||
|
}) {
|
||||||
|
log::error!(
|
||||||
|
"Failed to bind popup callback '{}': {}",
|
||||||
|
trigger_callback,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle(self, trigger_callback: &str) -> Result<()> {
|
||||||
|
let request = self.build_request();
|
||||||
|
let control = self.app.control();
|
||||||
|
let component_name = request.component.clone();
|
||||||
|
|
||||||
|
self.app.with_all_component_instances(|instance| {
|
||||||
|
let request_clone = request.clone();
|
||||||
|
let control_clone = control.clone();
|
||||||
|
let component_clone = component_name.clone();
|
||||||
|
|
||||||
|
if let Err(e) = instance.set_callback(trigger_callback, move |_args| {
|
||||||
|
log::debug!("Toggle callback for component: {}", component_clone);
|
||||||
|
if let Err(e) = control_clone.show_popup(&request_clone) {
|
||||||
|
log::error!("Failed to toggle popup: {}", e);
|
||||||
|
}
|
||||||
|
Value::Void
|
||||||
|
}) {
|
||||||
|
log::error!(
|
||||||
|
"Failed to bind toggle popup callback '{}': {}",
|
||||||
|
trigger_callback,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_request(&self) -> PopupRequest {
|
||||||
|
let mut builder = PopupRequest::builder(self.component.clone())
|
||||||
|
.at(self.reference)
|
||||||
|
.size(self.size)
|
||||||
|
.mode(self.anchor)
|
||||||
|
.grab(self.grab);
|
||||||
|
|
||||||
|
if let Some(ref close_cb) = self.close_callback {
|
||||||
|
builder = builder.close_on(close_cb.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref resize_cb) = self.resize_callback {
|
||||||
|
builder = builder.resize_on(resize_cb.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use crate::popup_builder::PopupBuilder;
|
||||||
|
use crate::value_conversion::IntoValue;
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use layer_shika_adapters::errors::EventLoopError;
|
use layer_shika_adapters::errors::EventLoopError;
|
||||||
use layer_shika_adapters::platform::calloop::{
|
use layer_shika_adapters::platform::calloop::{
|
||||||
|
|
@ -14,10 +16,13 @@ use layer_shika_adapters::{
|
||||||
use layer_shika_domain::config::WindowConfig;
|
use layer_shika_domain::config::WindowConfig;
|
||||||
use layer_shika_domain::entities::output_registry::OutputRegistry;
|
use layer_shika_domain::entities::output_registry::OutputRegistry;
|
||||||
use layer_shika_domain::errors::DomainError;
|
use layer_shika_domain::errors::DomainError;
|
||||||
|
use layer_shika_domain::value_objects::dimensions::PopupDimensions;
|
||||||
use layer_shika_domain::value_objects::output_handle::OutputHandle;
|
use layer_shika_domain::value_objects::output_handle::OutputHandle;
|
||||||
use layer_shika_domain::value_objects::output_info::OutputInfo;
|
use layer_shika_domain::value_objects::output_info::OutputInfo;
|
||||||
use layer_shika_domain::value_objects::dimensions::PopupDimensions;
|
use layer_shika_domain::value_objects::popup_positioning_mode::PopupPositioningMode;
|
||||||
use layer_shika_domain::value_objects::popup_request::{PopupHandle, PopupRequest, PopupSize};
|
use layer_shika_domain::value_objects::popup_request::{
|
||||||
|
PopupAt, PopupHandle, PopupRequest, PopupSize,
|
||||||
|
};
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::os::unix::io::AsFd;
|
use std::os::unix::io::AsFd;
|
||||||
|
|
@ -51,14 +56,39 @@ impl ShellControl {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn show_popup_at_cursor(&self, component: impl Into<String>) -> Result<()> {
|
||||||
|
let request = PopupRequest::builder(component.into())
|
||||||
|
.at(PopupAt::Cursor)
|
||||||
|
.build();
|
||||||
|
self.show_popup(&request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_popup_centered(&self, component: impl Into<String>) -> Result<()> {
|
||||||
|
let request = PopupRequest::builder(component.into())
|
||||||
|
.at(PopupAt::Cursor)
|
||||||
|
.mode(PopupPositioningMode::Center)
|
||||||
|
.build();
|
||||||
|
self.show_popup(&request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_popup_at_position(
|
||||||
|
&self,
|
||||||
|
component: impl Into<String>,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let request = PopupRequest::builder(component.into())
|
||||||
|
.at(PopupAt::Absolute { x, y })
|
||||||
|
.build();
|
||||||
|
self.show_popup(&request)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn close_popup(&self, handle: PopupHandle) -> Result<()> {
|
pub fn close_popup(&self, handle: PopupHandle) -> Result<()> {
|
||||||
self.sender
|
self.sender.send(PopupCommand::Close(handle)).map_err(|_| {
|
||||||
.send(PopupCommand::Close(handle))
|
Error::Domain(DomainError::Configuration {
|
||||||
.map_err(|_| {
|
message: "Failed to send popup close command: channel closed".to_string(),
|
||||||
Error::Domain(DomainError::Configuration {
|
|
||||||
message: "Failed to send popup close command: channel closed".to_string(),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resize_popup(&self, handle: PopupHandle, width: f32, height: f32) -> Result<()> {
|
pub fn resize_popup(&self, handle: PopupHandle, width: f32, height: f32) -> Result<()> {
|
||||||
|
|
@ -547,7 +577,11 @@ impl ShellContext<'_> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if control
|
if control
|
||||||
.resize_popup(PopupHandle::new(popup_key), dimensions.width, dimensions.height)
|
.resize_popup(
|
||||||
|
PopupHandle::new(popup_key),
|
||||||
|
dimensions.width,
|
||||||
|
dimensions.height,
|
||||||
|
)
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
log::error!("Failed to resize popup through control");
|
log::error!("Failed to resize popup through control");
|
||||||
|
|
@ -659,7 +693,8 @@ impl App {
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
PopupCommand::Show(request) => {
|
PopupCommand::Show(request) => {
|
||||||
if let Err(e) = shell_context.show_popup(&request, Some(control.clone()))
|
if let Err(e) =
|
||||||
|
shell_context.show_popup(&request, Some(control.clone()))
|
||||||
{
|
{
|
||||||
log::error!("Failed to show popup: {}", e);
|
log::error!("Failed to show popup: {}", e);
|
||||||
}
|
}
|
||||||
|
|
@ -674,9 +709,12 @@ impl App {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
} => {
|
} => {
|
||||||
if let Err(e) =
|
if let Err(e) = shell_context.resize_popup(
|
||||||
shell_context.resize_popup(handle, width, height, Some(control.clone()))
|
handle,
|
||||||
{
|
width,
|
||||||
|
height,
|
||||||
|
Some(control.clone()),
|
||||||
|
) {
|
||||||
log::error!("Failed to resize popup: {}", e);
|
log::error!("Failed to resize popup: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -709,17 +747,18 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_callback<F>(&self, callback_name: &str, handler: F) -> Result<()>
|
pub fn on<F, R>(&self, callback_name: &str, handler: F) -> Result<()>
|
||||||
where
|
where
|
||||||
F: Fn(&[Value], ShellControl) -> Value + 'static,
|
F: Fn(ShellControl) -> R + 'static,
|
||||||
|
R: IntoValue,
|
||||||
{
|
{
|
||||||
let control = self.control();
|
let control = self.control();
|
||||||
let handler = Rc::new(handler);
|
let handler = Rc::new(handler);
|
||||||
self.with_all_component_instances(|instance| {
|
self.with_all_component_instances(|instance| {
|
||||||
let handler_rc = Rc::clone(&handler);
|
let handler_rc = Rc::clone(&handler);
|
||||||
let control_clone = control.clone();
|
let control_clone = control.clone();
|
||||||
if let Err(e) = instance.set_callback(callback_name, move |args| {
|
if let Err(e) = instance.set_callback(callback_name, move |_args| {
|
||||||
handler_rc(args, control_clone.clone())
|
handler_rc(control_clone.clone()).into_value()
|
||||||
}) {
|
}) {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Failed to register callback '{}' on component: {}",
|
"Failed to register callback '{}' on component: {}",
|
||||||
|
|
@ -731,35 +770,34 @@ impl App {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bind_popup<F>(&self, callback_name: &str, config_builder: F) -> Result<()>
|
pub fn on_with_args<F, R>(&self, callback_name: &str, handler: F) -> Result<()>
|
||||||
where
|
where
|
||||||
F: Fn() -> PopupRequest + 'static,
|
F: Fn(&[Value], ShellControl) -> R + 'static,
|
||||||
|
R: IntoValue,
|
||||||
{
|
{
|
||||||
let control = self.control();
|
let control = self.control();
|
||||||
let builder = Rc::new(config_builder);
|
let handler = Rc::new(handler);
|
||||||
|
|
||||||
self.with_all_component_instances(|instance| {
|
self.with_all_component_instances(|instance| {
|
||||||
let builder_clone = Rc::clone(&builder);
|
let handler_rc = Rc::clone(&handler);
|
||||||
let control_clone = control.clone();
|
let control_clone = control.clone();
|
||||||
|
if let Err(e) = instance.set_callback(callback_name, move |args| {
|
||||||
if let Err(e) = instance.set_callback(callback_name, move |_args| {
|
handler_rc(args, control_clone.clone()).into_value()
|
||||||
let request = builder_clone();
|
|
||||||
if let Err(e) = control_clone.show_popup(&request) {
|
|
||||||
log::error!("Failed to show popup: {}", e);
|
|
||||||
}
|
|
||||||
Value::Void
|
|
||||||
}) {
|
}) {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Failed to bind popup callback '{}': {}",
|
"Failed to register callback '{}' on component: {}",
|
||||||
callback_name,
|
callback_name,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn popup(&self, component_name: impl Into<String>) -> PopupBuilder<'_> {
|
||||||
|
PopupBuilder::new(self, component_name.into())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run(&mut self) -> Result<()> {
|
pub fn run(&mut self) -> Result<()> {
|
||||||
self.inner.borrow_mut().run()?;
|
self.inner.borrow_mut().run()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
53
crates/composition/src/value_conversion.rs
Normal file
53
crates/composition/src/value_conversion.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
use layer_shika_adapters::platform::slint_interpreter::Value;
|
||||||
|
|
||||||
|
pub trait IntoValue {
|
||||||
|
fn into_value(self) -> Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoValue for () {
|
||||||
|
fn into_value(self) -> Value {
|
||||||
|
Value::Void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoValue for Value {
|
||||||
|
fn into_value(self) -> Value {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoValue for bool {
|
||||||
|
fn into_value(self) -> Value {
|
||||||
|
Value::Bool(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoValue for i32 {
|
||||||
|
fn into_value(self) -> Value {
|
||||||
|
Value::Number(f64::from(self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoValue for f32 {
|
||||||
|
fn into_value(self) -> Value {
|
||||||
|
Value::Number(f64::from(self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoValue for f64 {
|
||||||
|
fn into_value(self) -> Value {
|
||||||
|
Value::Number(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoValue for String {
|
||||||
|
fn into_value(self) -> Value {
|
||||||
|
Value::String(self.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoValue for &str {
|
||||||
|
fn into_value(self) -> Value {
|
||||||
|
Value::String(self.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue