parent
41e865c8d3
commit
0b94ae1eb8
8 changed files with 239 additions and 65 deletions
82
Cargo.lock
generated
82
Cargo.lock
generated
|
|
@ -205,6 +205,12 @@ dependencies = [
|
|||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.12"
|
||||
|
|
@ -236,13 +242,19 @@ dependencies = [
|
|||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
|
||||
[[package]]
|
||||
name = "hecs"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cbc675ee8d97b4d206a985137f8ad59666538f56f906474f554467a63c776d"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
"hashbrown 0.14.5",
|
||||
"hecs-macros",
|
||||
"spin",
|
||||
]
|
||||
|
|
@ -285,6 +297,16 @@ version = "1.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.16"
|
||||
|
|
@ -397,6 +419,28 @@ version = "0.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a"
|
||||
dependencies = [
|
||||
"num_enum_derive",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum_derive"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
|
|
@ -444,6 +488,15 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.95"
|
||||
|
|
@ -694,6 +747,23 @@ dependencies = [
|
|||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
|
|
@ -1007,6 +1077,15 @@ version = "0.53.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wl_drm"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1069,6 +1148,7 @@ dependencies = [
|
|||
"hecs",
|
||||
"log",
|
||||
"macros",
|
||||
"num_enum",
|
||||
"pretty_env_logger",
|
||||
"rustix",
|
||||
"sd-notify",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ smithay-client-toolkit = { version = "0.19.1", default-features = false }
|
|||
sd-notify = { version = "0.4.2", optional = true }
|
||||
macros = { version = "0.1.0", path = "macros" }
|
||||
hecs = { version = "0.10.5", features = ["macros"] }
|
||||
num_enum = "0.7.4"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
|
|
|||
|
|
@ -350,11 +350,6 @@ impl<S: X11Selection> Dispatch<WlPointer, Entity> for InnerServerState<S> {
|
|||
_: &DisplayHandle,
|
||||
_: &mut wayland_server::DataInit<'_, Self>,
|
||||
) {
|
||||
let c_pointer = state
|
||||
.world
|
||||
.get::<&client::wl_pointer::WlPointer>(*entity)
|
||||
.unwrap();
|
||||
|
||||
match request {
|
||||
Request::<WlPointer>::SetCursor {
|
||||
serial,
|
||||
|
|
@ -362,6 +357,11 @@ impl<S: X11Selection> Dispatch<WlPointer, Entity> for InnerServerState<S> {
|
|||
hotspot_y,
|
||||
surface,
|
||||
} => {
|
||||
let c_pointer = state
|
||||
.world
|
||||
.get::<&client::wl_pointer::WlPointer>(*entity)
|
||||
.unwrap();
|
||||
|
||||
let c_surface = surface.and_then(|s| {
|
||||
let e = s.data().copied()?;
|
||||
Some(
|
||||
|
|
@ -374,9 +374,11 @@ impl<S: X11Selection> Dispatch<WlPointer, Entity> for InnerServerState<S> {
|
|||
c_pointer.set_cursor(serial, c_surface.as_deref(), hotspot_x, hotspot_y);
|
||||
}
|
||||
Request::<WlPointer>::Release => {
|
||||
c_pointer.release();
|
||||
drop(c_pointer);
|
||||
let _ = state.world.despawn(*entity);
|
||||
let (client, _) = state
|
||||
.world
|
||||
.remove::<(client::wl_pointer::WlPointer, WlPointer)>(*entity)
|
||||
.unwrap();
|
||||
client.release();
|
||||
}
|
||||
_ => warn!("unhandled cursor request: {request:?}"),
|
||||
}
|
||||
|
|
@ -395,12 +397,11 @@ impl<S: X11Selection> Dispatch<WlKeyboard, Entity> for InnerServerState<S> {
|
|||
) {
|
||||
match request {
|
||||
Request::<WlKeyboard>::Release => {
|
||||
state
|
||||
let (client, _) = state
|
||||
.world
|
||||
.get::<&client::wl_keyboard::WlKeyboard>(*entity)
|
||||
.unwrap()
|
||||
.release();
|
||||
state.world.despawn(*entity).unwrap();
|
||||
.remove::<(client::wl_keyboard::WlKeyboard, WlKeyboard)>(*entity)
|
||||
.unwrap();
|
||||
client.release();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
|
@ -443,16 +444,15 @@ impl<S: X11Selection> Dispatch<WlSeat, Entity> for InnerServerState<S> {
|
|||
) {
|
||||
match request {
|
||||
Request::<WlSeat>::GetPointer { id } => {
|
||||
let new_entity = state.world.reserve_entity();
|
||||
let client = {
|
||||
state
|
||||
.world
|
||||
.get::<&client::wl_seat::WlSeat>(*entity)
|
||||
.unwrap()
|
||||
.get_pointer(&state.qh, new_entity)
|
||||
.get_pointer(&state.qh, *entity)
|
||||
};
|
||||
let server = data_init.init(id, new_entity);
|
||||
state.world.spawn_at(new_entity, (client, server));
|
||||
let server = data_init.init(id, *entity);
|
||||
state.world.insert(*entity, (client, server)).unwrap();
|
||||
}
|
||||
Request::<WlSeat>::GetKeyboard { id } => {
|
||||
let client = {
|
||||
|
|
|
|||
|
|
@ -435,6 +435,8 @@ impl Event for client::wl_seat::Event {
|
|||
}
|
||||
|
||||
struct PendingEnter(client::wl_pointer::Event);
|
||||
struct CurrentSurface(Entity);
|
||||
pub struct LastClickSerial(pub client::wl_seat::WlSeat, pub u32);
|
||||
|
||||
impl Event for client::wl_pointer::Event {
|
||||
fn handle<C: XConnection>(self, target: Entity, state: &mut ServerState<C>) {
|
||||
|
|
@ -459,7 +461,8 @@ impl Event for client::wl_pointer::Event {
|
|||
let mut cmd = CommandBuffer::new();
|
||||
let pending_enter = state.world.remove_one::<PendingEnter>(target).ok();
|
||||
let server = state.world.get::<&WlPointer>(target).unwrap();
|
||||
let mut query = surface.data().copied().and_then(|e| {
|
||||
let surface_entity = surface.data().copied();
|
||||
let mut query = surface_entity.and_then(|e| {
|
||||
state
|
||||
.world
|
||||
.query_one::<(&WlSurface, &SurfaceRole, &SurfaceScaleFactor, &x::Window)>(e)
|
||||
|
|
@ -481,6 +484,7 @@ impl Event for client::wl_pointer::Event {
|
|||
if !surface_is_popup {
|
||||
state.last_hovered = Some(*window);
|
||||
}
|
||||
cmd.insert(target, (CurrentSurface(surface_entity.unwrap()),));
|
||||
};
|
||||
|
||||
if !surface_is_popup {
|
||||
|
|
@ -512,11 +516,11 @@ impl Event for client::wl_pointer::Event {
|
|||
}
|
||||
client::wl_pointer::Event::Leave { serial, surface } => {
|
||||
let _ = state.world.remove_one::<PendingEnter>(target);
|
||||
let _ = state.world.remove_one::<CurrentSurface>(target);
|
||||
if !surface.is_alive() {
|
||||
return;
|
||||
}
|
||||
debug!("leaving surface ({serial})");
|
||||
let _ = state.world.remove_one::<PendingEnter>(target);
|
||||
if let Some(surface) = surface
|
||||
.data()
|
||||
.copied()
|
||||
|
|
@ -584,17 +588,39 @@ impl Event for client::wl_pointer::Event {
|
|||
}
|
||||
}
|
||||
}
|
||||
client::wl_pointer::Event::Button {
|
||||
serial,
|
||||
time,
|
||||
button,
|
||||
state: button_state,
|
||||
} => {
|
||||
let mut cmd = CommandBuffer::new();
|
||||
let (server, seat, CurrentSurface(surface)) = state
|
||||
.world
|
||||
.query_one_mut::<(&WlPointer, &client::wl_seat::WlSeat, &CurrentSurface)>(
|
||||
target,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// from linux/input-event-codes.h
|
||||
mod button_codes {
|
||||
pub const LEFT: u32 = 0x110;
|
||||
}
|
||||
|
||||
if button_state == WEnum::Value(client::wl_pointer::ButtonState::Pressed)
|
||||
&& button == button_codes::LEFT
|
||||
{
|
||||
cmd.insert(*surface, (LastClickSerial(seat.clone(), serial),));
|
||||
}
|
||||
|
||||
server.button(serial, time, button, convert_wenum(button_state));
|
||||
cmd.run_on(&mut state.world);
|
||||
}
|
||||
_ => {
|
||||
let server = state.world.get::<&WlPointer>(target).unwrap();
|
||||
simple_event_shunt! {
|
||||
server, self => [
|
||||
Frame,
|
||||
Button {
|
||||
serial,
|
||||
time,
|
||||
button,
|
||||
|state| convert_wenum(state)
|
||||
},
|
||||
Axis {
|
||||
time,
|
||||
|axis| convert_wenum(axis),
|
||||
|
|
|
|||
|
|
@ -1091,6 +1091,31 @@ impl<S: X11Selection + 'static> InnerServerState<S> {
|
|||
);
|
||||
}
|
||||
|
||||
pub fn move_window(&mut self, window: x::Window) {
|
||||
let Some(data) = self
|
||||
.windows
|
||||
.get(&window)
|
||||
.copied()
|
||||
.and_then(|e| self.world.entity(e).ok())
|
||||
else {
|
||||
warn!("Requested move of unknown window {window:?}");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(last_click_data) = data.get::<&LastClickSerial>() else {
|
||||
warn!("Requested move of window {window:?} but we don't have a click serial for it");
|
||||
return;
|
||||
};
|
||||
|
||||
let role = data.get::<&SurfaceRole>();
|
||||
let Some(SurfaceRole::Toplevel(Some(data))) = role.as_deref() else {
|
||||
warn!("Requested move of non toplevel {window:?} ({role:?})");
|
||||
return;
|
||||
};
|
||||
|
||||
data.toplevel._move(&last_click_data.0, last_click_data.1);
|
||||
}
|
||||
|
||||
pub fn destroy_window(&mut self, window: x::Window) {
|
||||
if let Some(id) = self.windows.remove(&window) {
|
||||
self.world.remove::<(x::Window, WindowData)>(id).unwrap();
|
||||
|
|
|
|||
|
|
@ -509,6 +509,28 @@ impl XState {
|
|||
x if x == self.atoms.active_win => {
|
||||
server_state.activate_window(e.window());
|
||||
}
|
||||
x if x == self.atoms.moveresize => {
|
||||
let x::ClientMessageData::Data32(data) = e.data() else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
let (_x_root, _y_root) = (data[0], data[1]);
|
||||
let Ok(direction) = MoveResizeDirection::try_from(data[2]) else {
|
||||
warn!("unknown direction for _NET_WM_MOVERESIZE: {}", data[2]);
|
||||
continue;
|
||||
};
|
||||
let button = data[3];
|
||||
// XXX: This can technically be driven by keyboard events and other mouse buttons as well,
|
||||
// but I haven't found an application that does this yet. We'll cross that bridge when we get to it.
|
||||
if button != 1 {
|
||||
warn!("Attempted move/resize of {:?} with non left click button ({button})", e.window());
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(direction, MoveResizeDirection::Move) {
|
||||
server_state.move_window(e.window());
|
||||
}
|
||||
}
|
||||
t => warn!("unrecognized message: {t:?}"),
|
||||
},
|
||||
xcb::Event::X(x::Event::MappingNotify(_)) => {}
|
||||
|
|
@ -933,6 +955,7 @@ xcb::atoms_struct! {
|
|||
xsettings => b"_XSETTINGS_S0" only_if_exists = false,
|
||||
xsettings_settings => b"_XSETTINGS_SETTINGS" only_if_exists = false,
|
||||
primary => b"PRIMARY" only_if_exists = false,
|
||||
moveresize => b"_NET_WM_MOVERESIZE" only_if_exists = false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1076,24 +1099,13 @@ mod motif {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, num_enum::TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum Decorations {
|
||||
Client = 0,
|
||||
Server = 1,
|
||||
}
|
||||
|
||||
impl TryFrom<u32> for Decorations {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: u32) -> Result<Self, ()> {
|
||||
match value {
|
||||
0 => Ok(Self::Client),
|
||||
1 => Ok(Self::Server),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Decorations> for zxdg_toplevel_decoration_v1::Mode {
|
||||
fn from(value: Decorations) -> Self {
|
||||
match value {
|
||||
|
|
@ -1104,42 +1116,37 @@ mod motif {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, num_enum::TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum SetState {
|
||||
Remove,
|
||||
Add,
|
||||
Toggle,
|
||||
}
|
||||
|
||||
impl TryFrom<u32> for SetState {
|
||||
type Error = ();
|
||||
fn try_from(value: u32) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(Self::Remove),
|
||||
1 => Ok(Self::Add),
|
||||
2 => Ok(Self::Toggle),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, num_enum::TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum WmState {
|
||||
Withdrawn = 0,
|
||||
Normal = 1,
|
||||
Iconic = 3,
|
||||
}
|
||||
|
||||
impl TryFrom<u32> for WmState {
|
||||
type Error = ();
|
||||
fn try_from(value: u32) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(Self::Withdrawn),
|
||||
1 => Ok(Self::Normal),
|
||||
3 => Ok(Self::Iconic),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Copy, Clone, num_enum::TryFromPrimitive, num_enum::IntoPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum MoveResizeDirection {
|
||||
SizeTopLeft,
|
||||
SizeTop,
|
||||
SizeTopRight,
|
||||
SizeRight,
|
||||
SizeBottomRight,
|
||||
SizeBottom,
|
||||
SizeBottomLeft,
|
||||
SizeLeft,
|
||||
Move,
|
||||
SizeKeyboard,
|
||||
MoveKeyboard,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub struct RealConnection {
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ use std::time::{Duration, Instant};
|
|||
use wayland_protocols::xdg::{
|
||||
decoration::zv1::server::zxdg_toplevel_decoration_v1, shell::server::xdg_toplevel,
|
||||
};
|
||||
use wayland_server::protocol::wl_output;
|
||||
use wayland_server::protocol::{wl_output, wl_pointer};
|
||||
use wayland_server::Resource;
|
||||
use xcb::{x, Xid};
|
||||
use xwayland_satellite as xwls;
|
||||
use xwayland_satellite::xstate::{WmSizeHintsFlags, WmState};
|
||||
use xwayland_satellite::xstate::{MoveResizeDirection, WmSizeHintsFlags, WmState};
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestDataInner {
|
||||
|
|
@ -329,6 +329,7 @@ xcb::atoms_struct! {
|
|||
incr => b"INCR",
|
||||
xsettings => b"_XSETTINGS_S0",
|
||||
xsettings_setting => b"_XSETTINGS_SETTINGS",
|
||||
moveresize => b"_NET_WM_MOVERESIZE",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2024,3 +2025,31 @@ fn rotated_output() {
|
|||
other => panic!("unexpected event {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
const BTN_LEFT: u32 = 0x110;
|
||||
|
||||
#[test]
|
||||
fn client_init_move() {
|
||||
let mut f = Fixture::new();
|
||||
let mut connection = Connection::new(&f.display);
|
||||
|
||||
let win_toplevel = connection.new_window(connection.root, 0, 0, 20, 20, false);
|
||||
let surface = f.map_as_toplevel(&mut connection, win_toplevel);
|
||||
f.testwl.move_pointer_to(surface, 10., 10.);
|
||||
let ptr = f.testwl.pointer();
|
||||
ptr.motion(10, 10.0, 10.0);
|
||||
ptr.frame();
|
||||
ptr.button(10, 20, BTN_LEFT, wl_pointer::ButtonState::Pressed);
|
||||
ptr.frame();
|
||||
f.testwl.dispatch();
|
||||
|
||||
connection.send_client_message(&x::ClientMessageEvent::new(
|
||||
win_toplevel,
|
||||
connection.atoms.moveresize,
|
||||
x::ClientMessageData::Data32([0, 0, MoveResizeDirection::Move.into(), 1, 0]),
|
||||
));
|
||||
|
||||
f.wait_and_dispatch();
|
||||
let data = f.testwl.get_surface_data(surface).unwrap();
|
||||
assert!(data.moving);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ pub struct SurfaceData {
|
|||
pub last_enter_serial: Option<u32>,
|
||||
pub fractional: Option<WpFractionalScaleV1>,
|
||||
pub viewport: Option<Viewport>,
|
||||
pub moving: bool,
|
||||
}
|
||||
|
||||
impl SurfaceData {
|
||||
|
|
@ -1541,6 +1542,10 @@ impl Dispatch<XdgToplevel, SurfaceId> for State {
|
|||
};
|
||||
toplevel.parent = parent;
|
||||
}
|
||||
xdg_toplevel::Request::Move { seat: _, serial: _ } => {
|
||||
let data = state.surfaces.get_mut(surface_id).unwrap();
|
||||
data.moving = true;
|
||||
}
|
||||
other => todo!("unhandled request {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -1862,6 +1867,7 @@ impl Dispatch<WlCompositor, ()> for State {
|
|||
last_enter_serial: None,
|
||||
fractional: None,
|
||||
viewport: None,
|
||||
moving: false,
|
||||
},
|
||||
);
|
||||
state.last_surface_id = Some(SurfaceId(id));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue