mirror of
https://github.com/pinnacle-comp/pinnacle.git
synced 2025-02-06 20:46:37 +01:00
Add output modes to API
This commit is contained in:
parent
869a2223f5
commit
2fd98301e6
4 changed files with 197 additions and 86 deletions
|
@ -12,6 +12,7 @@ local service = prefix .. "OutputService"
|
||||||
---@enum (key) OutputServiceMethod
|
---@enum (key) OutputServiceMethod
|
||||||
local rpc_types = {
|
local rpc_types = {
|
||||||
SetLocation = {},
|
SetLocation = {},
|
||||||
|
SetMode = {},
|
||||||
ConnectForAll = {
|
ConnectForAll = {
|
||||||
response_type = "ConnectForAllResponse",
|
response_type = "ConnectForAllResponse",
|
||||||
},
|
},
|
||||||
|
@ -319,51 +320,65 @@ function OutputHandle:set_loc_adj_to(other, alignment)
|
||||||
---@type integer
|
---@type integer
|
||||||
local y
|
local y
|
||||||
|
|
||||||
|
if not self_props.current_mode or not other_props.current_mode then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local self_width = self_props.current_mode.pixel_width
|
||||||
|
local self_height = self_props.current_mode.pixel_height
|
||||||
|
local other_width = other_props.current_mode.pixel_width
|
||||||
|
local other_height = other_props.current_mode.pixel_height
|
||||||
|
|
||||||
if dir == "top" or dir == "bottom" then
|
if dir == "top" or dir == "bottom" then
|
||||||
if dir == "top" then
|
if dir == "top" then
|
||||||
y = other_props.y - self_props.pixel_height
|
y = other_props.y - self_height
|
||||||
else
|
else
|
||||||
y = other_props.y + other_props.pixel_height
|
y = other_props.y + other_height
|
||||||
end
|
end
|
||||||
|
|
||||||
if align == "left" then
|
if align == "left" then
|
||||||
x = other_props.x
|
x = other_props.x
|
||||||
elseif align == "center" then
|
elseif align == "center" then
|
||||||
x = other_props.x + math.floor((other_props.pixel_width - self_props.pixel_width) / 2)
|
x = other_props.x + math.floor((other_width - self_width) / 2)
|
||||||
elseif align == "bottom" then
|
elseif align == "bottom" then
|
||||||
x = other_props.x + (other_props.pixel_width - self_props.pixel_width)
|
x = other_props.x + (other_width - self_width)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
if dir == "left" then
|
if dir == "left" then
|
||||||
x = other_props.x - self_props.pixel_width
|
x = other_props.x - self_width
|
||||||
else
|
else
|
||||||
x = other_props.x + other_props.pixel_width
|
x = other_props.x + other_width
|
||||||
end
|
end
|
||||||
|
|
||||||
if align == "top" then
|
if align == "top" then
|
||||||
y = other_props.y
|
y = other_props.y
|
||||||
elseif align == "center" then
|
elseif align == "center" then
|
||||||
y = other_props.y + math.floor((other_props.pixel_height - self_props.pixel_height) / 2)
|
y = other_props.y + math.floor((other_height - self_height) / 2)
|
||||||
elseif align == "bottom" then
|
elseif align == "bottom" then
|
||||||
y = other_props.y + (other_props.pixel_height - self_props.pixel_height)
|
y = other_props.y + (other_height - self_height)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
self:set_location({ x = x, y = y })
|
self:set_location({ x = x, y = y })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@class Mode
|
||||||
|
---@field pixel_width integer
|
||||||
|
---@field pixel_height integer
|
||||||
|
---@field refresh_rate_millihz integer
|
||||||
|
|
||||||
---@class OutputProperties
|
---@class OutputProperties
|
||||||
---@field make string?
|
---@field make string?
|
||||||
---@field model string?
|
---@field model string?
|
||||||
---@field x integer?
|
---@field x integer?
|
||||||
---@field y integer?
|
---@field y integer?
|
||||||
---@field pixel_width integer?
|
---@field current_mode Mode?
|
||||||
---@field pixel_height integer?
|
---@field preferred_mode Mode?
|
||||||
---@field refresh_rate integer?
|
---@field modes Mode[]
|
||||||
---@field physical_width integer?
|
---@field physical_width integer?
|
||||||
---@field physical_height integer?
|
---@field physical_height integer?
|
||||||
---@field focused boolean?
|
---@field focused boolean?
|
||||||
---@field tags TagHandle[]?
|
---@field tags TagHandle[]
|
||||||
|
|
||||||
---Get all properties of this output.
|
---Get all properties of this output.
|
||||||
---
|
---
|
||||||
|
@ -376,6 +391,7 @@ function OutputHandle:props()
|
||||||
|
|
||||||
response.tags = handles
|
response.tags = handles
|
||||||
response.tag_ids = nil
|
response.tag_ids = nil
|
||||||
|
response.modes = response.modes or {}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
end
|
end
|
||||||
|
@ -420,33 +436,31 @@ function OutputHandle:y()
|
||||||
return self:props().y
|
return self:props().y
|
||||||
end
|
end
|
||||||
|
|
||||||
---Get this output's width in pixels.
|
---Get this output's current mode.
|
||||||
---
|
---
|
||||||
---Shorthand for `handle:props().pixel_width`.
|
---Shorthand for `handle:props().current_mode`.
|
||||||
---
|
---
|
||||||
---@return integer?
|
---@return Mode?
|
||||||
function OutputHandle:pixel_width()
|
function OutputHandle:current_mode()
|
||||||
return self:props().pixel_width
|
return self:props().current_mode
|
||||||
end
|
end
|
||||||
|
|
||||||
---Get this output's height in pixels.
|
---Get this output's preferred mode.
|
||||||
---
|
---
|
||||||
---Shorthand for `handle:props().pixel_height`.
|
---Shorthand for `handle:props().preferred_mode`.
|
||||||
---
|
---
|
||||||
---@return integer?
|
---@return Mode?
|
||||||
function OutputHandle:pixel_height()
|
function OutputHandle:preferred_mode()
|
||||||
return self:props().pixel_height
|
return self:props().preferred_mode
|
||||||
end
|
end
|
||||||
|
|
||||||
---Get this output's refresh rate in millihertz.
|
---Get all of this output's available modes.
|
||||||
---
|
---
|
||||||
---For example, 144Hz is returned as 144000.
|
---Shorthand for `handle:props().modes`.
|
||||||
---
|
---
|
||||||
---Shorthand for `handle:props().refresh_rate`.
|
---@return Mode[]
|
||||||
---
|
function OutputHandle:modes()
|
||||||
---@return integer?
|
return self:props().modes
|
||||||
function OutputHandle:refresh_rate()
|
|
||||||
return self:props().refresh_rate
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---Get this output's physical width in millimeters.
|
---Get this output's physical width in millimeters.
|
||||||
|
|
|
@ -4,12 +4,23 @@ package pinnacle.output.v0alpha1;
|
||||||
|
|
||||||
import "google/protobuf/empty.proto";
|
import "google/protobuf/empty.proto";
|
||||||
|
|
||||||
|
message Mode {
|
||||||
|
optional uint32 pixel_width = 1;
|
||||||
|
optional uint32 pixel_height = 2;
|
||||||
|
optional uint32 refresh_rate_millihz = 3;
|
||||||
|
}
|
||||||
|
|
||||||
message SetLocationRequest {
|
message SetLocationRequest {
|
||||||
optional string output_name = 1;
|
optional string output_name = 1;
|
||||||
optional int32 x = 2;
|
optional int32 x = 2;
|
||||||
optional int32 y = 3;
|
optional int32 y = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message SetModeRequest {
|
||||||
|
optional string output_name = 1;
|
||||||
|
optional Mode mode = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message GetRequest {}
|
message GetRequest {}
|
||||||
message GetResponse {
|
message GetResponse {
|
||||||
repeated string output_names = 1;
|
repeated string output_names = 1;
|
||||||
|
@ -19,13 +30,24 @@ message GetPropertiesRequest {
|
||||||
optional string output_name = 1;
|
optional string output_name = 1;
|
||||||
}
|
}
|
||||||
message GetPropertiesResponse {
|
message GetPropertiesResponse {
|
||||||
|
// The monitor's manufacturer
|
||||||
optional string make = 1;
|
optional string make = 1;
|
||||||
|
// The model of the monitor
|
||||||
optional string model = 2;
|
optional string model = 2;
|
||||||
|
// The x-coord of the output in the global space
|
||||||
optional int32 x = 3;
|
optional int32 x = 3;
|
||||||
|
// The y coord of the output in the global space
|
||||||
optional int32 y = 4;
|
optional int32 y = 4;
|
||||||
optional uint32 pixel_width = 5;
|
// NULLABLE
|
||||||
optional uint32 pixel_height = 6;
|
//
|
||||||
optional uint32 refresh_rate = 7;
|
// The current mode
|
||||||
|
optional Mode current_mode = 5;
|
||||||
|
// NULLABLE
|
||||||
|
//
|
||||||
|
// The preferred mode
|
||||||
|
optional Mode preferred_mode = 6;
|
||||||
|
// All available modes
|
||||||
|
repeated Mode modes = 7;
|
||||||
// In millimeters
|
// In millimeters
|
||||||
optional uint32 physical_width = 8;
|
optional uint32 physical_width = 8;
|
||||||
// In millimeters
|
// In millimeters
|
||||||
|
@ -36,6 +58,7 @@ message GetPropertiesResponse {
|
||||||
|
|
||||||
service OutputService {
|
service OutputService {
|
||||||
rpc SetLocation(SetLocationRequest) returns (google.protobuf.Empty);
|
rpc SetLocation(SetLocationRequest) returns (google.protobuf.Empty);
|
||||||
|
rpc SetMode(SetModeRequest) returns (google.protobuf.Empty);
|
||||||
rpc Get(GetRequest) returns (GetResponse);
|
rpc Get(GetRequest) returns (GetResponse);
|
||||||
rpc GetProperties(GetPropertiesRequest) returns (GetPropertiesResponse);
|
rpc GetProperties(GetPropertiesRequest) returns (GetPropertiesResponse);
|
||||||
}
|
}
|
||||||
|
|
|
@ -300,11 +300,13 @@ impl OutputHandle {
|
||||||
let attempt_set_loc = || -> Option<()> {
|
let attempt_set_loc = || -> Option<()> {
|
||||||
let other_x = other_props.x?;
|
let other_x = other_props.x?;
|
||||||
let other_y = other_props.y?;
|
let other_y = other_props.y?;
|
||||||
let other_width = other_props.pixel_width? as i32;
|
let other_mode = other_props.current_mode?;
|
||||||
let other_height = other_props.pixel_height? as i32;
|
let other_width = other_mode.pixel_width as i32;
|
||||||
|
let other_height = other_mode.pixel_height as i32;
|
||||||
|
|
||||||
let self_width = self_props.pixel_width? as i32;
|
let self_mode = self_props.current_mode?;
|
||||||
let self_height = self_props.pixel_height? as i32;
|
let self_width = self_mode.pixel_width as i32;
|
||||||
|
let self_height = self_mode.pixel_height as i32;
|
||||||
|
|
||||||
use Alignment::*;
|
use Alignment::*;
|
||||||
|
|
||||||
|
@ -399,9 +401,31 @@ impl OutputHandle {
|
||||||
model: response.model,
|
model: response.model,
|
||||||
x: response.x,
|
x: response.x,
|
||||||
y: response.y,
|
y: response.y,
|
||||||
pixel_width: response.pixel_width,
|
current_mode: response.current_mode.and_then(|mode| {
|
||||||
pixel_height: response.pixel_height,
|
Some(Mode {
|
||||||
refresh_rate: response.refresh_rate,
|
pixel_width: mode.pixel_width?,
|
||||||
|
pixel_height: mode.pixel_height?,
|
||||||
|
refresh_rate_millihertz: mode.refresh_rate_millihz?,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
preferred_mode: response.preferred_mode.and_then(|mode| {
|
||||||
|
Some(Mode {
|
||||||
|
pixel_width: mode.pixel_width?,
|
||||||
|
pixel_height: mode.pixel_height?,
|
||||||
|
refresh_rate_millihertz: mode.refresh_rate_millihz?,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
modes: response
|
||||||
|
.modes
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|mode| {
|
||||||
|
Some(Mode {
|
||||||
|
pixel_width: mode.pixel_width?,
|
||||||
|
pixel_height: mode.pixel_height?,
|
||||||
|
refresh_rate_millihertz: mode.refresh_rate_millihz?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
physical_width: response.physical_width,
|
physical_width: response.physical_width,
|
||||||
physical_height: response.physical_height,
|
physical_height: response.physical_height,
|
||||||
focused: response.focused,
|
focused: response.focused,
|
||||||
|
@ -463,42 +487,40 @@ impl OutputHandle {
|
||||||
self.props_async().await.y
|
self.props_async().await.y
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get this output's screen width in pixels.
|
/// Get this output's current mode.
|
||||||
///
|
///
|
||||||
/// Shorthand for `self.props().pixel_width`.
|
/// Shorthand for `self.props().current_mode`.
|
||||||
pub fn pixel_width(&self) -> Option<u32> {
|
pub fn current_mode(&self) -> Option<Mode> {
|
||||||
self.props().pixel_width
|
self.props().current_mode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The async version of [`OutputHandle::pixel_width`].
|
/// The async version of [`OutputHandle::current_mode`].
|
||||||
pub async fn pixel_width_async(&self) -> Option<u32> {
|
pub async fn current_mode_async(&self) -> Option<Mode> {
|
||||||
self.props_async().await.pixel_width
|
self.props_async().await.current_mode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get this output's screen height in pixels.
|
/// Get this output's preferred mode.
|
||||||
///
|
///
|
||||||
/// Shorthand for `self.props().pixel_height`.
|
/// Shorthand for `self.props().preferred_mode`.
|
||||||
pub fn pixel_height(&self) -> Option<u32> {
|
pub fn preferred_mode(&self) -> Option<Mode> {
|
||||||
self.props().pixel_height
|
self.props().preferred_mode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The async version of [`OutputHandle::pixel_height`].
|
/// The async version of [`OutputHandle::preferred_mode`].
|
||||||
pub async fn pixel_height_async(&self) -> Option<u32> {
|
pub async fn preferred_mode_async(&self) -> Option<Mode> {
|
||||||
self.props_async().await.pixel_height
|
self.props_async().await.preferred_mode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get this output's refresh rate in millihertz.
|
/// Get all available modes this output supports.
|
||||||
///
|
///
|
||||||
/// For example, 144Hz will be returned as 144000.
|
/// Shorthand for `self.props().modes`.
|
||||||
///
|
pub fn modes(&self) -> Vec<Mode> {
|
||||||
/// Shorthand for `self.props().refresh_rate`.
|
self.props().modes
|
||||||
pub fn refresh_rate(&self) -> Option<u32> {
|
|
||||||
self.props().refresh_rate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The async version of [`OutputHandle::refresh_rate`].
|
/// The async version of [`OutputHandle::modes`].
|
||||||
pub async fn refresh_rate_async(&self) -> Option<u32> {
|
pub async fn modes_async(&self) -> Vec<Mode> {
|
||||||
self.props_async().await.refresh_rate
|
self.props_async().await.modes
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get this output's physical width in millimeters.
|
/// Get this output's physical width in millimeters.
|
||||||
|
@ -557,34 +579,47 @@ impl OutputHandle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A possible output pixel dimension and refresh rate configuration.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
|
||||||
|
pub struct Mode {
|
||||||
|
/// The width of the output, in pixels.
|
||||||
|
pub pixel_width: u32,
|
||||||
|
/// The height of the output, in pixels.
|
||||||
|
pub pixel_height: u32,
|
||||||
|
/// The output's refresh rate, in millihertz.
|
||||||
|
///
|
||||||
|
/// For example, 60Hz is returned as 60000.
|
||||||
|
pub refresh_rate_millihertz: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// The properties of an output.
|
/// The properties of an output.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
|
||||||
pub struct OutputProperties {
|
pub struct OutputProperties {
|
||||||
/// The make of the output
|
/// The make of the output.
|
||||||
pub make: Option<String>,
|
pub make: Option<String>,
|
||||||
/// The model of the output
|
/// The model of the output.
|
||||||
///
|
///
|
||||||
/// This is something like "27GL83A" or whatever crap monitor manufacturers name their monitors
|
/// This is something like "27GL83A" or whatever crap monitor manufacturers name their monitors
|
||||||
/// these days.
|
/// these days.
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
/// The x position of the output in the global space
|
/// The x position of the output in the global space.
|
||||||
pub x: Option<i32>,
|
pub x: Option<i32>,
|
||||||
/// The y position of the output in the global space
|
/// The y position of the output in the global space.
|
||||||
pub y: Option<i32>,
|
pub y: Option<i32>,
|
||||||
/// The output's screen width in pixels
|
/// The output's current mode.
|
||||||
pub pixel_width: Option<u32>,
|
pub current_mode: Option<Mode>,
|
||||||
/// The output's screen height in pixels
|
/// The output's preferred mode.
|
||||||
pub pixel_height: Option<u32>,
|
pub preferred_mode: Option<Mode>,
|
||||||
/// The output's refresh rate in millihertz
|
/// All available modes the output supports.
|
||||||
pub refresh_rate: Option<u32>,
|
pub modes: Vec<Mode>,
|
||||||
/// The output's physical width in millimeters
|
/// The output's physical width in millimeters.
|
||||||
pub physical_width: Option<u32>,
|
pub physical_width: Option<u32>,
|
||||||
/// The output's physical height in millimeters
|
/// The output's physical height in millimeters.
|
||||||
pub physical_height: Option<u32>,
|
pub physical_height: Option<u32>,
|
||||||
/// Whether this output is focused or not
|
/// Whether this output is focused or not.
|
||||||
///
|
///
|
||||||
/// This is currently implemented as the output with the most recent pointer motion.
|
/// This is currently implemented as the output with the most recent pointer motion.
|
||||||
pub focused: Option<bool>,
|
pub focused: Option<bool>,
|
||||||
/// The tags this output has
|
/// The tags this output has.
|
||||||
pub tags: Vec<TagHandle>,
|
pub tags: Vec<TagHandle>,
|
||||||
}
|
}
|
||||||
|
|
59
src/api.rs
59
src/api.rs
|
@ -14,7 +14,7 @@ use pinnacle_api_defs::pinnacle::{
|
||||||
},
|
},
|
||||||
output::{
|
output::{
|
||||||
self,
|
self,
|
||||||
v0alpha1::{output_service_server, SetLocationRequest},
|
v0alpha1::{output_service_server, SetLocationRequest, SetModeRequest},
|
||||||
},
|
},
|
||||||
process::v0alpha1::{process_service_server, SetEnvRequest, SpawnRequest, SpawnResponse},
|
process::v0alpha1::{process_service_server, SetEnvRequest, SpawnRequest, SpawnResponse},
|
||||||
tag::{
|
tag::{
|
||||||
|
@ -949,6 +949,30 @@ impl output_service_server::OutputService for OutputService {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_mode(&self, request: Request<SetModeRequest>) -> Result<Response<()>, Status> {
|
||||||
|
let request = request.into_inner();
|
||||||
|
|
||||||
|
run_unary_no_response(&self.sender, |state| {
|
||||||
|
let Some(output) = request
|
||||||
|
.output_name
|
||||||
|
.map(OutputName)
|
||||||
|
.and_then(|name| name.output(state))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mode = request.mode.and_then(|mode| {
|
||||||
|
Some(smithay::output::Mode {
|
||||||
|
size: (mode.pixel_width? as i32, mode.pixel_height? as i32).into(),
|
||||||
|
refresh: mode.refresh_rate_millihz? as i32,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
output.change_current_state(mode, None, None, None);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get(
|
async fn get(
|
||||||
&self,
|
&self,
|
||||||
_request: Request<output::v0alpha1::GetRequest>,
|
_request: Request<output::v0alpha1::GetRequest>,
|
||||||
|
@ -977,20 +1001,35 @@ impl output_service_server::OutputService for OutputService {
|
||||||
.ok_or_else(|| Status::invalid_argument("no output specified"))?,
|
.ok_or_else(|| Status::invalid_argument("no output specified"))?,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let from_smithay_mode = |mode: smithay::output::Mode| -> output::v0alpha1::Mode {
|
||||||
|
output::v0alpha1::Mode {
|
||||||
|
pixel_width: Some(mode.size.w as u32),
|
||||||
|
pixel_height: Some(mode.size.h as u32),
|
||||||
|
refresh_rate_millihz: Some(mode.refresh as u32),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
run_unary(&self.sender, move |state| {
|
run_unary(&self.sender, move |state| {
|
||||||
let output = output_name.output(state);
|
let output = output_name.output(state);
|
||||||
|
|
||||||
let pixel_width = output
|
let current_mode = output
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|output| output.current_mode().map(|mode| mode.size.w as u32));
|
.and_then(|output| output.current_mode().map(from_smithay_mode));
|
||||||
|
|
||||||
let pixel_height = output
|
let preferred_mode = output
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|output| output.current_mode().map(|mode| mode.size.h as u32));
|
.and_then(|output| output.preferred_mode().map(from_smithay_mode));
|
||||||
|
|
||||||
let refresh_rate = output
|
let modes = output
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|output| output.current_mode().map(|mode| mode.refresh as u32));
|
.map(|output| {
|
||||||
|
output
|
||||||
|
.modes()
|
||||||
|
.into_iter()
|
||||||
|
.map(from_smithay_mode)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or(Vec::new());
|
||||||
|
|
||||||
let model = output
|
let model = output
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
@ -1030,9 +1069,9 @@ impl output_service_server::OutputService for OutputService {
|
||||||
model,
|
model,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
pixel_width,
|
current_mode,
|
||||||
pixel_height,
|
preferred_mode,
|
||||||
refresh_rate,
|
modes,
|
||||||
physical_width,
|
physical_width,
|
||||||
physical_height,
|
physical_height,
|
||||||
focused,
|
focused,
|
||||||
|
|
Loading…
Add table
Reference in a new issue