mirror of
https://codeberg.org/unspeaker/tengri.git
synced 2026-07-17 07:56:56 +02:00
drawing and singing fixes
This commit is contained in:
parent
e0781571c4
commit
0273d2ac75
7 changed files with 244 additions and 195 deletions
189
src/sing.rs
189
src/sing.rs
|
|
@ -1,6 +1,9 @@
|
|||
pub use ::jack::{*, contrib::{*, ClosureProcessHandler}};
|
||||
use crate::{*, time::PerfModel};
|
||||
use JackState::*;
|
||||
|
||||
mod jack; pub use self::jack::*;
|
||||
mod jack_event; pub use self::jack_event::*;
|
||||
mod jack_perf; pub use self::jack_perf::*;
|
||||
|
||||
/// Trait for thing that has a JACK process callback.
|
||||
pub trait Audio {
|
||||
|
|
@ -22,58 +25,6 @@ pub trait Audio {
|
|||
}
|
||||
}
|
||||
|
||||
/// Wraps [JackState], and through it [jack::Client] when connected.
|
||||
///
|
||||
/// ```
|
||||
/// let jack = tengri::sing::Jack::default();
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Default)] pub struct Jack<'j> (
|
||||
pub(crate) Arc<RwLock<JackState<'j>>>
|
||||
);
|
||||
|
||||
/// This is a connection which may be [Inactive], [Activating], or [Active].
|
||||
/// In the [Active] and [Inactive] states, [JackState::client] returns a
|
||||
/// [jack::Client], which you can use to talk to the JACK API.
|
||||
///
|
||||
/// ```
|
||||
/// let state = tengri::sing::JackState::default();
|
||||
/// ```
|
||||
#[derive(Debug, Default)] pub enum JackState<'j> {
|
||||
/// Unused
|
||||
#[default] Inert,
|
||||
/// Before activation.
|
||||
Inactive(Client),
|
||||
/// During activation.
|
||||
Activating,
|
||||
/// After activation. Must not be dropped for JACK thread to persist.
|
||||
Active(DynamicAsyncClient<'j>),
|
||||
}
|
||||
|
||||
/// Event enum for JACK events.
|
||||
///
|
||||
/// ```
|
||||
/// let event = tengri::sing::JackEvent::XRun; // kerpop
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq)] pub enum JackEvent {
|
||||
ThreadInit,
|
||||
Shutdown(ClientStatus, Arc<str>),
|
||||
Freewheel(bool),
|
||||
SampleRate(Frames),
|
||||
ClientRegistration(Arc<str>, bool),
|
||||
PortRegistration(PortId, bool),
|
||||
PortRename(PortId, Arc<str>, Arc<str>),
|
||||
PortsConnected(PortId, PortId, bool),
|
||||
GraphReorder,
|
||||
XRun,
|
||||
}
|
||||
|
||||
/// Generic notification handler that emits [JackEvent]
|
||||
///
|
||||
/// ```
|
||||
/// let notify = tengri::sing::JackNotify(|_|{});
|
||||
/// ```
|
||||
pub struct JackNotify<T: Fn(JackEvent) + Send>(pub T);
|
||||
|
||||
/// Running JACK [AsyncClient] with maximum type erasure.
|
||||
///
|
||||
/// One [Box] contains function that handles [JackEvent]s.
|
||||
|
|
@ -92,134 +43,6 @@ pub type DynamicAudioHandler<'j> =
|
|||
pub type BoxedAudioHandler<'j> =
|
||||
Box<dyn FnMut(&Client, &ProcessScope) -> Control + Send + Sync + 'j>;
|
||||
|
||||
/// Notification handler wrapper for [BoxedJackEventHandler].
|
||||
pub type DynamicNotifications<'j> =
|
||||
JackNotify<BoxedJackEventHandler<'j>>;
|
||||
|
||||
/// Boxed [JackEvent] callback.
|
||||
pub type BoxedJackEventHandler<'j> =
|
||||
Box<dyn Fn(JackEvent) + Send + Sync + 'j>;
|
||||
|
||||
impl<'j> HasJack<'j> for Jack<'j> { fn jack (&self) -> &Jack<'j> { self } }
|
||||
|
||||
impl<'j> HasJack<'j> for &Jack<'j> { fn jack (&self) -> &Jack<'j> { self } }
|
||||
|
||||
/// Implement [Jack] constructor and methods
|
||||
impl<'j> Jack<'j> {
|
||||
/// Register new [Client] and wrap it for shared use.
|
||||
pub fn new_run <T: HasJack<'j> + Audio + Send + Sync + 'static> (
|
||||
name: &impl AsRef<str>,
|
||||
init: impl FnOnce(Jack<'j>)->Usually<T>
|
||||
) -> Usually<Arc<RwLock<T>>> {
|
||||
Jack::new(name)?.run(init)
|
||||
}
|
||||
|
||||
pub fn new (name: &impl AsRef<str>) -> Usually<Self> {
|
||||
let client = Client::new(name.as_ref(), ClientOptions::NO_START_SERVER)?.0;
|
||||
Ok(Jack(Arc::new(RwLock::new(JackState::Inactive(client)))))
|
||||
}
|
||||
|
||||
pub fn run <T: HasJack<'j> + Audio + Send + Sync + 'static>
|
||||
(self, init: impl FnOnce(Self)->Usually<T>) -> Usually<Arc<RwLock<T>>>
|
||||
{
|
||||
let client_state = self.0.clone();
|
||||
let app: Arc<RwLock<T>> = Arc::new(RwLock::new(init(self)?));
|
||||
let mut state = Activating;
|
||||
std::mem::swap(&mut*client_state.write().unwrap(), &mut state);
|
||||
if let Inactive(client) = state {
|
||||
// This is the misc notifications handler. It's a struct that wraps a [Box]
|
||||
// which performs type erasure on a callback that takes [JackEvent], which is
|
||||
// one of the available misc notifications.
|
||||
let notify = JackNotify(Box::new({
|
||||
let app = app.clone();
|
||||
move|event|(&mut*app.write().unwrap()).handle(event)
|
||||
}) as BoxedJackEventHandler);
|
||||
// This is the main processing handler. It's a struct that wraps a [Box]
|
||||
// which performs type erasure on a callback that takes [Client] and [ProcessScope]
|
||||
// and passes them down to the `app`'s `process` callback, which in turn
|
||||
// implements audio and MIDI input and output on a realtime basis.
|
||||
let process = ::jack::contrib::ClosureProcessHandler::new(Box::new({
|
||||
let app = app.clone();
|
||||
move|c: &_, s: &_|if let Ok(mut app) = app.write() {
|
||||
app.process(c, s)
|
||||
} else {
|
||||
Control::Quit
|
||||
}
|
||||
}) as BoxedAudioHandler);
|
||||
// Launch a client with the two handlers.
|
||||
*client_state.write().unwrap() = Active(
|
||||
client.activate_async(notify, process)?
|
||||
);
|
||||
} else {
|
||||
unreachable!();
|
||||
}
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// Run something with the client.
|
||||
pub fn with_client <T> (&self, op: impl FnOnce(&Client)->T) -> T {
|
||||
match &*self.0.read().unwrap() {
|
||||
Inert => panic!("jack client not activated"),
|
||||
Inactive(client) => op(client),
|
||||
Activating => panic!("jack client has not finished activation"),
|
||||
Active(client) => op(client.as_client()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Fn(JackEvent) + Send> NotificationHandler for JackNotify<T> {
|
||||
fn thread_init(&self, _: &Client) {
|
||||
self.0(JackEvent::ThreadInit);
|
||||
}
|
||||
unsafe fn shutdown(&mut self, status: ClientStatus, reason: &str) {
|
||||
self.0(JackEvent::Shutdown(status, reason.into()));
|
||||
}
|
||||
fn freewheel(&mut self, _: &Client, enabled: bool) {
|
||||
self.0(JackEvent::Freewheel(enabled));
|
||||
}
|
||||
fn sample_rate(&mut self, _: &Client, frames: Frames) -> Control {
|
||||
self.0(JackEvent::SampleRate(frames));
|
||||
Control::Quit
|
||||
}
|
||||
fn client_registration(&mut self, _: &Client, name: &str, reg: bool) {
|
||||
self.0(JackEvent::ClientRegistration(name.into(), reg));
|
||||
}
|
||||
fn port_registration(&mut self, _: &Client, id: PortId, reg: bool) {
|
||||
self.0(JackEvent::PortRegistration(id, reg));
|
||||
}
|
||||
fn port_rename(&mut self, _: &Client, id: PortId, old: &str, new: &str) -> Control {
|
||||
self.0(JackEvent::PortRename(id, old.into(), new.into()));
|
||||
Control::Continue
|
||||
}
|
||||
fn ports_connected(&mut self, _: &Client, a: PortId, b: PortId, are: bool) {
|
||||
self.0(JackEvent::PortsConnected(a, b, are));
|
||||
}
|
||||
fn graph_reorder(&mut self, _: &Client) -> Control {
|
||||
self.0(JackEvent::GraphReorder);
|
||||
Control::Continue
|
||||
}
|
||||
fn xrun(&mut self, _: &Client) -> Control {
|
||||
self.0(JackEvent::XRun);
|
||||
Control::Continue
|
||||
}
|
||||
}
|
||||
|
||||
impl JackPerfModel for PerfModel {
|
||||
fn update_from_jack_scope (&self, t0: Option<u64>, scope: &ProcessScope) {
|
||||
if let Some(t0) = t0 {
|
||||
let t1 = self.clock.raw();
|
||||
self.used.store(
|
||||
self.clock.delta_as_nanos(t0, t1) as f64,
|
||||
Relaxed,
|
||||
);
|
||||
self.window.store(
|
||||
scope.cycle_times().unwrap().period_usecs as f64,
|
||||
Relaxed,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Things that can provide a [jack::Client] reference.
|
||||
///
|
||||
/// ```
|
||||
|
|
@ -266,10 +89,6 @@ pub trait HasJack<'j>: Send + Sync {
|
|||
}
|
||||
}
|
||||
|
||||
pub trait JackPerfModel {
|
||||
fn update_from_jack_scope (&self, t0: Option<u64>, scope: &ProcessScope);
|
||||
}
|
||||
|
||||
/// Implement [Audio]: provide JACK callbacks.
|
||||
#[macro_export] macro_rules! impl_audio {
|
||||
|
||||
|
|
|
|||
98
src/sing/jack.rs
Normal file
98
src/sing/jack.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use crate::{*, sing::*, time::PerfModel};
|
||||
pub use ::jack::{*, contrib::{*, ClosureProcessHandler}};
|
||||
|
||||
use JackState::*;
|
||||
|
||||
/// Wraps [JackState], and through it [jack::Client] when connected.
|
||||
///
|
||||
/// ```
|
||||
/// let jack = tengri::sing::Jack::default();
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Default)] pub struct Jack<'j> (
|
||||
pub(crate) Arc<RwLock<JackState<'j>>>
|
||||
);
|
||||
|
||||
/// This is a connection which may be [Inactive], [Activating], or [Active].
|
||||
/// In the [Active] and [Inactive] states, [JackState::client] returns a
|
||||
/// [jack::Client], which you can use to talk to the JACK API.
|
||||
///
|
||||
/// ```
|
||||
/// let state = tengri::sing::JackState::default();
|
||||
/// ```
|
||||
#[derive(Debug, Default)] pub enum JackState<'j> {
|
||||
/// Unused
|
||||
#[default] Inert,
|
||||
/// Before activation.
|
||||
Inactive(Client),
|
||||
/// During activation.
|
||||
Activating,
|
||||
/// After activation. Must not be dropped for JACK thread to persist.
|
||||
Active(DynamicAsyncClient<'j>),
|
||||
}
|
||||
|
||||
/// Implement [Jack] constructor and methods
|
||||
impl<'j> Jack<'j> {
|
||||
/// Register new [Client] and wrap it for shared use.
|
||||
pub fn new_run <T: HasJack<'j> + Audio + Send + Sync + 'static> (
|
||||
name: impl AsRef<str>,
|
||||
init: impl FnOnce(Jack<'j>)->Usually<T>
|
||||
) -> Usually<Arc<RwLock<T>>> {
|
||||
Jack::new(name)?.run(init)
|
||||
}
|
||||
|
||||
pub fn new (name: impl AsRef<str>) -> Usually<Self> {
|
||||
let client = Client::new(name.as_ref(), ClientOptions::NO_START_SERVER)?.0;
|
||||
Ok(Jack(Arc::new(RwLock::new(JackState::Inactive(client)))))
|
||||
}
|
||||
|
||||
pub fn run <T: HasJack<'j> + Audio + Send + Sync + 'static>
|
||||
(self, init: impl FnOnce(Self)->Usually<T>) -> Usually<Arc<RwLock<T>>>
|
||||
{
|
||||
let client_state = self.0.clone();
|
||||
let app: Arc<RwLock<T>> = Arc::new(RwLock::new(init(self)?));
|
||||
let mut state = Activating;
|
||||
std::mem::swap(&mut*client_state.write().unwrap(), &mut state);
|
||||
if let Inactive(client) = state {
|
||||
// This is the misc notifications handler. It's a struct that wraps a [Box]
|
||||
// which performs type erasure on a callback that takes [JackEvent], which is
|
||||
// one of the available misc notifications.
|
||||
let notify = JackNotify(Box::new({
|
||||
let app = app.clone();
|
||||
move|event|(&mut*app.write().unwrap()).handle(event)
|
||||
}) as BoxedJackEventHandler);
|
||||
// This is the main processing handler. It's a struct that wraps a [Box]
|
||||
// which performs type erasure on a callback that takes [Client] and [ProcessScope]
|
||||
// and passes them down to the `app`'s `process` callback, which in turn
|
||||
// implements audio and MIDI input and output on a realtime basis.
|
||||
let process = ::jack::contrib::ClosureProcessHandler::new(Box::new({
|
||||
let app = app.clone();
|
||||
move|c: &_, s: &_|if let Ok(mut app) = app.write() {
|
||||
app.process(c, s)
|
||||
} else {
|
||||
Control::Quit
|
||||
}
|
||||
}) as BoxedAudioHandler);
|
||||
// Launch a client with the two handlers.
|
||||
*client_state.write().unwrap() = Active(
|
||||
client.activate_async(notify, process)?
|
||||
);
|
||||
} else {
|
||||
unreachable!();
|
||||
}
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// Run something with the client.
|
||||
pub fn with_client <T> (&self, op: impl FnOnce(&Client)->T) -> T {
|
||||
match &*self.0.read().unwrap() {
|
||||
Inert => panic!("jack client not activated"),
|
||||
Inactive(client) => op(client),
|
||||
Activating => panic!("jack client has not finished activation"),
|
||||
Active(client) => op(client.as_client()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'j> HasJack<'j> for Jack<'j> { fn jack (&self) -> &Jack<'j> { self } }
|
||||
|
||||
impl<'j> HasJack<'j> for &Jack<'j> { fn jack (&self) -> &Jack<'j> { self } }
|
||||
72
src/sing/jack_event.rs
Normal file
72
src/sing/jack_event.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use crate::{*, time::PerfModel};
|
||||
pub use ::jack::{*, contrib::{*, ClosureProcessHandler}};
|
||||
|
||||
/// Event enum for JACK events.
|
||||
///
|
||||
/// ```
|
||||
/// let event = tengri::sing::JackEvent::XRun; // kerpop
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq)] pub enum JackEvent {
|
||||
ThreadInit,
|
||||
Shutdown(ClientStatus, Arc<str>),
|
||||
Freewheel(bool),
|
||||
SampleRate(Frames),
|
||||
ClientRegistration(Arc<str>, bool),
|
||||
PortRegistration(PortId, bool),
|
||||
PortRename(PortId, Arc<str>, Arc<str>),
|
||||
PortsConnected(PortId, PortId, bool),
|
||||
GraphReorder,
|
||||
XRun,
|
||||
}
|
||||
|
||||
/// Generic notification handler that emits [JackEvent]
|
||||
///
|
||||
/// ```
|
||||
/// let notify = tengri::sing::JackNotify(|_|{});
|
||||
/// ```
|
||||
pub struct JackNotify<T: Fn(JackEvent) + Send>(pub T);
|
||||
|
||||
/// Notification handler wrapper for [BoxedJackEventHandler].
|
||||
pub type DynamicNotifications<'j> =
|
||||
JackNotify<BoxedJackEventHandler<'j>>;
|
||||
|
||||
/// Boxed [JackEvent] callback.
|
||||
pub type BoxedJackEventHandler<'j> =
|
||||
Box<dyn Fn(JackEvent) + Send + Sync + 'j>;
|
||||
|
||||
impl<T: Fn(JackEvent) + Send> NotificationHandler for JackNotify<T> {
|
||||
fn thread_init(&self, _: &Client) {
|
||||
self.0(JackEvent::ThreadInit);
|
||||
}
|
||||
unsafe fn shutdown(&mut self, status: ClientStatus, reason: &str) {
|
||||
self.0(JackEvent::Shutdown(status, reason.into()));
|
||||
}
|
||||
fn freewheel(&mut self, _: &Client, enabled: bool) {
|
||||
self.0(JackEvent::Freewheel(enabled));
|
||||
}
|
||||
fn sample_rate(&mut self, _: &Client, frames: Frames) -> Control {
|
||||
self.0(JackEvent::SampleRate(frames));
|
||||
Control::Quit
|
||||
}
|
||||
fn client_registration(&mut self, _: &Client, name: &str, reg: bool) {
|
||||
self.0(JackEvent::ClientRegistration(name.into(), reg));
|
||||
}
|
||||
fn port_registration(&mut self, _: &Client, id: PortId, reg: bool) {
|
||||
self.0(JackEvent::PortRegistration(id, reg));
|
||||
}
|
||||
fn port_rename(&mut self, _: &Client, id: PortId, old: &str, new: &str) -> Control {
|
||||
self.0(JackEvent::PortRename(id, old.into(), new.into()));
|
||||
Control::Continue
|
||||
}
|
||||
fn ports_connected(&mut self, _: &Client, a: PortId, b: PortId, are: bool) {
|
||||
self.0(JackEvent::PortsConnected(a, b, are));
|
||||
}
|
||||
fn graph_reorder(&mut self, _: &Client) -> Control {
|
||||
self.0(JackEvent::GraphReorder);
|
||||
Control::Continue
|
||||
}
|
||||
fn xrun(&mut self, _: &Client) -> Control {
|
||||
self.0(JackEvent::XRun);
|
||||
Control::Continue
|
||||
}
|
||||
}
|
||||
22
src/sing/jack_perf.rs
Normal file
22
src/sing/jack_perf.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use crate::{*, time::PerfModel};
|
||||
pub use ::jack::{*, contrib::{*, ClosureProcessHandler}};
|
||||
|
||||
pub trait JackPerfModel {
|
||||
fn update_from_jack_scope (&self, t0: Option<u64>, scope: &ProcessScope);
|
||||
}
|
||||
|
||||
impl JackPerfModel for PerfModel {
|
||||
fn update_from_jack_scope (&self, t0: Option<u64>, scope: &ProcessScope) {
|
||||
if let Some(t0) = t0 {
|
||||
let t1 = self.clock.raw();
|
||||
self.used.store(
|
||||
self.clock.delta_as_nanos(t0, t1) as f64,
|
||||
Relaxed,
|
||||
);
|
||||
self.window.store(
|
||||
scope.cycle_times().unwrap().period_usecs as f64,
|
||||
Relaxed,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,20 @@
|
|||
use super::*;
|
||||
|
||||
/// Iterate over a collection of renderables:
|
||||
/// Iterate over a collection of the same kind of [Draw]able:
|
||||
pub fn iter <'a, S: Screen, D: 'a, I: Iterator<Item = D>, U: Draw<S>> (
|
||||
_iter: impl Fn()->I, _draw: impl Fn(D, usize)->U,
|
||||
) -> impl Draw<S> {
|
||||
thunk(move|_to: &mut S|{ todo!() })
|
||||
}
|
||||
|
||||
/// Iterate over a collection of the same kind of [Draw]able:
|
||||
pub fn iter_once <'a, S: Screen, D: 'a, U: Draw<S>> (
|
||||
_iter: impl Iterator<Item = D>, _draw: impl Fn(D, usize)->U,
|
||||
) -> impl Draw<S> {
|
||||
thunk(move|_to: &mut S|{ todo!() })
|
||||
}
|
||||
|
||||
/// Iterate over a collection of various [Draw]ables:
|
||||
///
|
||||
/// ```
|
||||
/// use tengri::draw::{Origin::*, Split::*};
|
||||
|
|
@ -20,12 +34,6 @@ pub fn iter_dyn <
|
|||
thunk(move|_to: &mut S|{ todo!() })
|
||||
}
|
||||
|
||||
pub fn iter <'a, S: Screen, D: 'a, I: Iterator<Item = D>, U: Draw<S>> (
|
||||
_iter: impl Fn()->I, _draw: impl Fn(D, usize)->U,
|
||||
) -> impl Draw<S> {
|
||||
thunk(move|_to: &mut S|{ todo!() })
|
||||
}
|
||||
|
||||
pub fn iter_north <'a, S: Screen, D: 'a, I: Iterator<Item = D>, U: Draw<S>> (
|
||||
_iter: impl Fn()->I, _draw: impl Fn(D, usize)->U,
|
||||
) -> impl Draw<S> {
|
||||
|
|
@ -61,3 +69,27 @@ pub fn iter_west <'a, S: Screen, D: 'a, I: Iterator<Item = D>, U: Draw<S>> (
|
|||
) -> impl Draw<S> {
|
||||
thunk(move|_to: &mut S|{ todo!() })
|
||||
}
|
||||
|
||||
pub fn row_south <S: Screen> (south: S::Unit, height: S::Unit, content: impl Draw<S>)
|
||||
-> impl Draw<S>
|
||||
{
|
||||
y_push(south, h_exact(height, content))
|
||||
}
|
||||
|
||||
pub fn row_north <S: Screen> (north: S::Unit, height: S::Unit, content: impl Draw<S>)
|
||||
-> impl Draw<S>
|
||||
{
|
||||
y_pull(north, h_exact(height, content))
|
||||
}
|
||||
|
||||
pub fn col_east <S: Screen> (east: S::Unit, width: S::Unit, content: impl Draw<S>)
|
||||
-> impl Draw<S>
|
||||
{
|
||||
x_push(east, h_exact(width, content))
|
||||
}
|
||||
|
||||
pub fn col_west <S: Screen> (west: S::Unit, width: S::Unit, content: impl Draw<S>)
|
||||
-> impl Draw<S>
|
||||
{
|
||||
x_pull(west, h_exact(width, content))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,14 @@ use super::*;
|
|||
|
||||
/// Uses [AtomicUsize] to measure size during\
|
||||
/// rendering (which is normally read-only).
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Size(AtomicUsize, AtomicUsize);
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct Size(Arc<AtomicUsize>, Arc<AtomicUsize>);
|
||||
|
||||
impl PartialEq for Size {
|
||||
fn eq (&self, _: &Self) -> bool {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl X<u16> for Size {
|
||||
fn x (&self) -> u16 { 0 }
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ pub(crate) use ::unicode_width::*;
|
|||
}
|
||||
}
|
||||
|
||||
impl Draw<Tui> for &std::sync::Arc<str> {
|
||||
impl Draw<Tui> for std::sync::Arc<str> {
|
||||
fn draw (self, to: &mut Tui) -> Usually<XYWH<u16>> {
|
||||
self.as_ref().draw(to)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue