mirror of
https://codeberg.org/unspeaker/tek.git
synced 2025-12-06 19:56:42 +01:00
refactor: merge plugin, sampler -> mixer; transport -> sequencer; time -> core
This commit is contained in:
parent
6206a43b4a
commit
a659062dbc
46 changed files with 128 additions and 198 deletions
36
Cargo.lock
generated
36
Cargo.lock
generated
|
|
@ -2514,16 +2514,14 @@ dependencies = [
|
|||
"tek_core",
|
||||
"tek_jack",
|
||||
"tek_mixer",
|
||||
"tek_plugin",
|
||||
"tek_sampler",
|
||||
"tek_sequencer",
|
||||
"tek_timer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tek_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"atomic_float",
|
||||
"backtrace",
|
||||
"better-panic",
|
||||
"clap",
|
||||
|
|
@ -2546,33 +2544,15 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "tek_mixer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"tek_core",
|
||||
"tek_jack",
|
||||
"tek_plugin",
|
||||
"tek_sampler",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tek_plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"livi",
|
||||
"suil-rs",
|
||||
"tek_core",
|
||||
"tek_jack",
|
||||
"vst",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tek_sampler"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"symphonia",
|
||||
"tek_core",
|
||||
"tek_jack",
|
||||
"vst",
|
||||
"wavers",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2581,16 +2561,6 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"tek_core",
|
||||
"tek_jack",
|
||||
"tek_timer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tek_timer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"atomic_float",
|
||||
"tek_core",
|
||||
"tek_jack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -9,11 +9,8 @@ microxdg = "0.1.2"
|
|||
|
||||
tek_core = { path = "../tek_core" }
|
||||
tek_jack = { path = "../tek_jack" }
|
||||
tek_plugin = { path = "../tek_plugin" }
|
||||
tek_sampler = { path = "../tek_sampler" }
|
||||
tek_sequencer = { path = "../tek_sequencer" }
|
||||
tek_timer = { path = "../tek_timer" }
|
||||
tek_mixer = { path = "../tek_mixer", features = ["standalone_devices"] }
|
||||
tek_mixer = { path = "../tek_mixer" }
|
||||
#jack = "0.10"
|
||||
#crossterm = "0.27"
|
||||
#ratatui = { version = "0.26.3", features = [ "unstable-widget-ref", "underline-color" ] }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use crate::*;
|
||||
use tek_core::Direction;
|
||||
use tek_timer::TransportToolbar;
|
||||
use tek_sequencer::Arranger;
|
||||
use tek_sequencer::{TransportToolbar, Arranger};
|
||||
use tek_mixer::Mixer;
|
||||
|
||||
/// Root of application state.
|
||||
|
|
@ -108,7 +107,7 @@ render!(App |self, buf, area| {
|
|||
focused: self.section == AppFocus::Chain,
|
||||
chain: self.mixer.track()
|
||||
},
|
||||
&self.arranger.sequencer,
|
||||
&self.arranger.sequencer(),
|
||||
]))
|
||||
]).render(buf, area)?;
|
||||
if let Some(ref modal) = *MODAL.lock().unwrap() {
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ fn handle_modal (e: &AppEvent) -> Usually<bool> {
|
|||
fn handle_focused (state: &mut App, e: &AppEvent) -> Usually<bool> {
|
||||
match state.section {
|
||||
AppFocus::Transport => state.transport.handle(e),
|
||||
AppFocus::Arranger => state.arranger.sequencer.handle(e),
|
||||
AppFocus::Sequencer => state.arranger.sequencer.handle(e),
|
||||
AppFocus::Arranger => state.arranger.sequencer_mut().map(|s|s.handle(e)),
|
||||
AppFocus::Sequencer => state.arranger.sequencer_mut().map(|s|s.handle(e)),
|
||||
AppFocus::Chain => Ok(false)/*if state.entered {
|
||||
handle_device(state, e)? ||
|
||||
handle_keymap(state, e, crate::control::KEYMAP_CHAIN)?
|
||||
|
|
@ -85,11 +85,11 @@ pub const KEYMAP_GLOBAL: &'static [KeyBinding<App>] = keymap!(App {
|
|||
Ok(true)
|
||||
}],
|
||||
[Char('='), NONE, "zoom_in", "show fewer ticks per block", |app: &mut App| {
|
||||
app.arranger.sequencer.time_axis.scale_mut(&prev_note_length);
|
||||
app.arranger.sequencer_mut().map(|s|s.time_axis.scale_mut(&prev_note_length));
|
||||
Ok(true)
|
||||
}],
|
||||
[Char('-'), NONE, "zoom_out", "show more ticks per block", |app: &mut App| {
|
||||
app.arranger.sequencer.time_axis.scale_mut(&next_note_length);
|
||||
app.arranger.sequencer_mut().map(|s|s.time_axis.scale_mut(&next_note_length));
|
||||
Ok(true)
|
||||
}],
|
||||
[Char('x'), NONE, "extend", "double the current clip", |app: &mut App| {
|
||||
|
|
@ -144,14 +144,14 @@ pub const KEYMAP_FOCUS: &'static [KeyBinding<App>] = keymap!(App {
|
|||
app.entered = false;
|
||||
app.transport.entered = app.entered;
|
||||
app.arranger.entered = app.entered;
|
||||
app.arranger.sequencer.entered = app.entered;
|
||||
app.arranger.sequencer_mut().map(|s|s.entered = app.entered);
|
||||
Ok(true)
|
||||
}],
|
||||
[Enter, NONE, "focus_enter", "activate item at cursor", |app: &mut App|{
|
||||
app.entered = true;
|
||||
app.transport.entered = app.entered;
|
||||
app.arranger.entered = app.entered;
|
||||
app.arranger.sequencer.entered = app.entered;
|
||||
app.arranger.sequencer_mut().map(|s|s.entered = app.entered);
|
||||
Ok(true)
|
||||
}],
|
||||
});
|
||||
|
|
@ -162,8 +162,10 @@ pub fn focus_next (app: &mut App) -> Usually<bool> {
|
|||
app.transport.entered = app.entered;
|
||||
app.arranger.focused = app.section == AppFocus::Arranger;
|
||||
app.arranger.entered = app.entered;
|
||||
app.arranger.sequencer.focused = app.section == AppFocus::Sequencer;
|
||||
app.arranger.sequencer.entered = app.entered;
|
||||
app.arranger.sequencer_mut().map(|s|{
|
||||
s.focused = app.section == AppFocus::Sequencer;
|
||||
s.entered = app.entered;
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +175,9 @@ pub fn focus_prev (app: &mut App) -> Usually<bool> {
|
|||
app.transport.entered = app.entered;
|
||||
app.arranger.focused = app.section == AppFocus::Arranger;
|
||||
app.arranger.entered = app.entered;
|
||||
app.arranger.sequencer.focused = app.section == AppFocus::Sequencer;
|
||||
app.arranger.sequencer.entered = app.entered;
|
||||
app.arranger.sequencer_mut().map(|s|{
|
||||
s.focused = app.section == AppFocus::Sequencer;
|
||||
s.entered = app.entered;
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
pub(crate) use tek_core::*;
|
||||
pub(crate) use tek_jack::{*, jack::*};
|
||||
pub(crate) use tek_timer::*;
|
||||
pub(crate) use tek_sequencer::*;
|
||||
pub(crate) use microxdg::XdgApp;
|
||||
|
||||
submod! {
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ midly = "0.5"
|
|||
clap = { version = "4.5.4", features = [ "derive" ] }
|
||||
clojure-reader = "0.1.0"
|
||||
once_cell = "1.19.0"
|
||||
atomic_float = "1.0.0"
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ pub use ratatui::prelude::{Rect, Style, Color, Buffer};
|
|||
pub use ratatui::style::Stylize;
|
||||
pub use clojure_reader::{edn::{read, Edn}, error::Error as EdnError};
|
||||
pub use once_cell::sync::Lazy;
|
||||
pub use std::sync::atomic::{Ordering, AtomicBool};
|
||||
|
||||
pub(crate) use std::error::Error;
|
||||
pub(crate) use std::io::{stdout};
|
||||
pub(crate) use std::thread::{spawn, JoinHandle};
|
||||
pub(crate) use std::time::Duration;
|
||||
pub(crate) use std::sync::atomic::{Ordering, AtomicBool};
|
||||
pub(crate) use atomic_float::*;
|
||||
//, LockResult, RwLockReadGuard, RwLockWriteGuard};
|
||||
//pub(crate) use std::path::PathBuf;
|
||||
//pub(crate) use std::fs::read_dir;
|
||||
|
|
@ -41,7 +42,12 @@ use crossterm::terminal::{
|
|||
}
|
||||
|
||||
submod! {
|
||||
exit render handle
|
||||
exit
|
||||
render
|
||||
handle
|
||||
time_base
|
||||
time_note
|
||||
time_tick
|
||||
}
|
||||
|
||||
/// EDN parsing helper.
|
||||
|
|
|
|||
|
|
@ -145,6 +145,15 @@ impl Render for () {
|
|||
}
|
||||
}
|
||||
|
||||
impl<T: Render> Render for Option<T> {
|
||||
fn render (&self, b: &mut Buffer, a: Rect) -> Usually<Rect> {
|
||||
match self {
|
||||
Some(widget) => widget.render(b, a),
|
||||
None => ().render(b, a),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Fn(&mut Buffer, Rect) -> Usually<Rect> + Send> Render for T {
|
||||
fn render (&self, b: &mut Buffer, a: Rect) -> Usually<Rect> {
|
||||
(*self)(b, a)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,3 @@
|
|||
pub(crate) use tek_core::*;
|
||||
pub(crate) use tek_core::crossterm::event::{KeyCode, KeyModifiers};
|
||||
pub(crate) use tek_jack::{*, jack::*};
|
||||
pub(crate) use std::sync::{Arc, atomic::Ordering};
|
||||
pub(crate) use atomic_float::AtomicF64;
|
||||
submod! {
|
||||
timebase
|
||||
ticks
|
||||
transport
|
||||
transport_focus
|
||||
transport_handle
|
||||
transport_render
|
||||
}
|
||||
|
||||
/// (pulses, name)
|
||||
pub const NOTE_DURATIONS: [(usize, &str);26] = [
|
||||
(1, "1/384"),
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
use crate::*;
|
||||
|
||||
/// Defines frames per tick.
|
||||
pub struct Ticks(pub f64);
|
||||
|
||||
|
|
@ -6,13 +6,14 @@ version = "0.1.0"
|
|||
[dependencies]
|
||||
tek_core = { path = "../tek_core" }
|
||||
tek_jack = { path = "../tek_jack" }
|
||||
tek_sampler = { path = "../tek_sampler", optional = true }
|
||||
tek_plugin = { path = "../tek_plugin", optional = true }
|
||||
|
||||
[features]
|
||||
standalone_devices = [ "sampler", "plugin" ]
|
||||
sampler = [ "tek_sampler" ]
|
||||
plugin = [ "tek_plugin" ]
|
||||
livi = "0.7.4"
|
||||
suil-rs = { path = "../suil" }
|
||||
symphonia = { version = "0.5.4", features = [ "all" ] }
|
||||
vst = "0.4.0"
|
||||
#vst3 = "0.1.0"
|
||||
wavers = "1.4.3"
|
||||
winit = { version = "0.30.4", features = [ "x11" ] }
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -24,3 +25,11 @@ path = "src/mixer_main.rs"
|
|||
[[bin]]
|
||||
name = "tek_track"
|
||||
path = "src/track_main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "tek_sampler"
|
||||
path = "src/sampler_main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "tek_plugin"
|
||||
path = "src/sampler_main.rs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
# `tek_mixer` and `tek_track`
|
||||
# `tek_mixer`
|
||||
|
||||
// TODO:
|
||||
// - Meters: propagate clipping:
|
||||
// - If one stage clips, all stages after it are marked red
|
||||
// - If one track clips, all tracks that feed from it are marked red?
|
||||
|
||||
# `tek_track`
|
||||
|
||||
# `tek_sampler`
|
||||
|
||||
This crate implements a sampler device which plays audio files
|
||||
in response to MIDI notes.
|
||||
|
||||
# `tek_plugin`
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
pub(crate) use tek_core::*;
|
||||
pub(crate) use tek_core::ratatui::prelude::*;
|
||||
pub(crate) use tek_core::crossterm::event::{KeyCode, KeyModifiers};
|
||||
pub(crate) use tek_core::midly::{num::u7, live::LiveEvent, MidiMessage};
|
||||
pub(crate) use tek_jack::{*, jack::*};
|
||||
|
||||
pub(crate) use std::collections::BTreeMap;
|
||||
pub(crate) use std::sync::{Arc, Mutex, RwLock};
|
||||
pub(crate) use std::path::PathBuf;
|
||||
pub(crate) use std::ffi::OsString;
|
||||
pub(crate) use std::fs::read_dir;
|
||||
|
||||
submod! {
|
||||
mixer
|
||||
mixer_cli
|
||||
|
|
@ -10,4 +18,14 @@ submod! {
|
|||
track
|
||||
track_view
|
||||
track_handle
|
||||
plugin
|
||||
plugin_lv2
|
||||
plugin_lv2_gui
|
||||
plugin_vst2
|
||||
plugin_vst3
|
||||
sample
|
||||
sample_add
|
||||
sampler
|
||||
sampler_edn
|
||||
voice
|
||||
}
|
||||
|
|
|
|||
1
crates/tek_mixer/src/plugin_vst3.rs
Normal file
1
crates/tek_mixer/src/plugin_vst3.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
//! TODO
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
[package]
|
||||
name = "tek_plugin"
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
|
||||
[dependencies]
|
||||
tek_core = { path = "../tek_core" }
|
||||
tek_jack = { path = "../tek_jack" }
|
||||
livi = "0.7.4"
|
||||
winit = { version = "0.30.4", features = [ "x11" ] }
|
||||
suil-rs = { path = "../suil" }
|
||||
vst = "0.4.0"
|
||||
#vst3 = "0.1.0"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "tek_plugin"
|
||||
path = "src/main.rs"
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# `tek_plugin`
|
||||
|
||||
This crate allows plugins to be loaded.
|
||||
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
pub(crate) use tek_core::*;
|
||||
pub(crate) use tek_core::ratatui::prelude::*;
|
||||
pub(crate) use tek_core::crossterm::event::{KeyCode, KeyModifiers};
|
||||
pub(crate) use tek_jack::*;
|
||||
pub(crate) use tek_jack::jack::*;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
submod! {
|
||||
plugin
|
||||
lv2
|
||||
lv2_gui
|
||||
vst2
|
||||
vst3
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
use crate::*;
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
[package]
|
||||
name = "tek_sampler"
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
|
||||
[dependencies]
|
||||
tek_core = { path = "../tek_core" }
|
||||
tek_jack = { path = "../tek_jack" }
|
||||
symphonia = { version = "0.5.4", features = [ "all" ] }
|
||||
wavers = "1.4.3"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "tek_sampler"
|
||||
path = "src/main.rs"
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# `tek_sampler`
|
||||
|
||||
This crate implements a sampler device which plays audio files
|
||||
in response to MIDI notes.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
// Sampler (currently 16bit WAVs at system rate; TODO convert/resample)
|
||||
|
||||
pub(crate) use tek_core::*;
|
||||
pub(crate) use tek_core::ratatui::prelude::*;
|
||||
pub(crate) use tek_core::crossterm::event::{KeyCode, KeyModifiers};
|
||||
pub(crate) use tek_core::midly::{num::u7, live::LiveEvent, MidiMessage};
|
||||
pub(crate) use tek_jack::{*, jack::*};
|
||||
|
||||
pub(crate) use std::collections::BTreeMap;
|
||||
pub(crate) use std::sync::{Arc, Mutex, RwLock};
|
||||
pub(crate) use std::path::PathBuf;
|
||||
pub(crate) use std::ffi::OsString;
|
||||
pub(crate) use std::fs::read_dir;
|
||||
|
||||
submod! {
|
||||
sampler
|
||||
sample
|
||||
sample_add
|
||||
voice
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ version = "0.1.0"
|
|||
[dependencies]
|
||||
tek_core = { path = "../tek_core" }
|
||||
tek_jack = { path = "../tek_jack" }
|
||||
tek_timer = { path = "../tek_timer" }
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
|
@ -18,3 +17,7 @@ path = "src/sequencer_main.rs"
|
|||
[[bin]]
|
||||
name = "tek_arranger"
|
||||
path = "src/arranger_main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "tek_transport"
|
||||
path = "src/transport_main.rs"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,23 @@
|
|||
# `tek_sequencer`
|
||||
|
||||
This crate implements a MIDI sequencer and arranger with clip launching.
|
||||
|
||||
---
|
||||
|
||||
# `tek_arranger`
|
||||
|
||||
---
|
||||
|
||||
# `tek_timer`
|
||||
|
||||
This crate implements time sync and JACK transport control.
|
||||
|
||||
* Warning: If transport is set rolling by qjackctl, this program can't pause it
|
||||
* Todo: bpm: shift +/- 0.001
|
||||
* Todo: quant/sync: shift = next/prev value of same type (normal, triplet, dotted)
|
||||
* Or: use shift to switch between inc/dec top/bottom value?
|
||||
* Todo: focus play button
|
||||
* Todo: focus time position
|
||||
* Todo: edit numeric values
|
||||
* Todo: jump to time/bbt markers
|
||||
* Todo: count xruns
|
||||
|
|
|
|||
|
|
@ -49,6 +49,16 @@ impl Arranger {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn sequencer (&self) -> Option<&Sequencer> {
|
||||
self.selected.track()
|
||||
.map(|track|self.tracks.get(track))
|
||||
.flatten()
|
||||
}
|
||||
pub fn sequencer_mut (&mut self) -> Option<&mut Sequencer> {
|
||||
self.selected.track()
|
||||
.map(|track|self.tracks.get_mut(track))
|
||||
.flatten()
|
||||
}
|
||||
pub fn show_phrase (&mut self) -> Usually<()> {
|
||||
unimplemented!()
|
||||
//let phrase = self.phrase();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use tek_core::clap::{self, Parser};
|
||||
use tek_timer::TransportToolbar;
|
||||
use crate::*;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
|
|
|
|||
|
|
@ -4,24 +4,27 @@ pub(crate) use tek_core::*;
|
|||
pub(crate) use tek_core::ratatui::prelude::*;
|
||||
pub(crate) use tek_core::crossterm::event::{KeyCode, KeyModifiers};
|
||||
pub(crate) use tek_core::midly::{num::u7, live::LiveEvent, MidiMessage};
|
||||
pub(crate) use tek_jack::jack::*;
|
||||
pub(crate) use tek_timer::*;
|
||||
pub(crate) use tek_jack::{*, jack::*};
|
||||
pub(crate) use std::sync::{Arc, RwLock};
|
||||
|
||||
submod! {
|
||||
midi
|
||||
phrase
|
||||
sequencer
|
||||
sequencer_cli
|
||||
sequencer_handle
|
||||
sequencer_render
|
||||
arranger
|
||||
arranger_cli
|
||||
arranger_focus
|
||||
arranger_handle
|
||||
arranger_track
|
||||
arranger_view
|
||||
midi
|
||||
phrase
|
||||
scene
|
||||
sequencer
|
||||
sequencer_cli
|
||||
sequencer_handle
|
||||
sequencer_render
|
||||
transport
|
||||
transport_focus
|
||||
transport_handle
|
||||
transport_render
|
||||
}
|
||||
|
||||
pubmod! {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use tek_core::clap::{self, Parser};
|
||||
use tek_timer::TransportToolbar;
|
||||
use crate::*;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
[package]
|
||||
name = "tek_timer"
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
|
||||
[dependencies]
|
||||
tek_core = { path = "../tek_core" }
|
||||
tek_jack = { path = "../tek_jack" }
|
||||
atomic_float = "1.0.0"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "tek_timer"
|
||||
path = "src/transport_main.rs"
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
# `tek_timer`
|
||||
|
||||
This crate implements time sync and JACK transport control.
|
||||
|
||||
* Warning: If transport is set rolling by qjackctl, this program can't pause it
|
||||
* Todo: bpm: shift +/- 0.001
|
||||
* Todo: quant/sync: shift = next/prev value of same type (normal, triplet, dotted)
|
||||
* Or: use shift to switch between inc/dec top/bottom value?
|
||||
* Todo: focus play button
|
||||
* Todo: focus time position
|
||||
* Todo: edit numeric values
|
||||
* Todo: jump to time/bbt markers
|
||||
* Todo: count xruns
|
||||
Loading…
Add table
Add a link
Reference in a new issue