mirror of
https://codeberg.org/unspeaker/tek.git
synced 2025-12-06 19:56:42 +01:00
wip: refactor pt.24: 45 errors
This commit is contained in:
parent
ff4698d046
commit
da074eb5fa
5 changed files with 120 additions and 199 deletions
|
|
@ -52,63 +52,48 @@ submod! {
|
|||
tui_transport_foc
|
||||
}
|
||||
|
||||
pub struct AppContainer<E, M, V, C, A, S>
|
||||
pub struct AppView<E, A, C>
|
||||
where
|
||||
E: Engine,
|
||||
M: Send + Sync,
|
||||
V: Widget<Engine = E> + Handle<E>,
|
||||
C: Command<V>,
|
||||
A: Audio,
|
||||
S: StatusBar<E>
|
||||
A: Widget<Engine = E> + Handle<E> + Audio,
|
||||
C: Command<A>,
|
||||
{
|
||||
pub app: A,
|
||||
pub cursor: (usize, usize),
|
||||
pub entered: bool,
|
||||
pub menu_bar: Option<MenuBar<E, V, C>>,
|
||||
pub status_bar: Option<S>,
|
||||
pub menu_bar: Option<MenuBar<E, A, C>>,
|
||||
pub status_bar: Option<Box<dyn Widget<Engine = E>>>,
|
||||
pub history: Vec<C>,
|
||||
pub size: Measure<E>,
|
||||
pub model: Arc<RwLock<M>>,
|
||||
pub view: V,
|
||||
pub audio: A,
|
||||
}
|
||||
|
||||
impl<E, M, V, C, A, S> AppContainer<E, M, V, C, A, S>
|
||||
impl<E, A, C> AppView<E, A, C>
|
||||
where
|
||||
E: Engine,
|
||||
M: Send + Sync,
|
||||
V: Widget<Engine = E> + Handle<E>,
|
||||
C: Command<V>,
|
||||
A: Audio,
|
||||
S: StatusBar<E>
|
||||
A: Widget<Engine = E> + Handle<E> + Audio,
|
||||
C: Command<A>
|
||||
{
|
||||
pub fn new (
|
||||
model: &Arc<RwLock<M>>,
|
||||
view: V,
|
||||
audio: A,
|
||||
menu_bar: Option<MenuBar<E, V, C>>,
|
||||
status_bar: Option<S>,
|
||||
app: A,
|
||||
menu_bar: Option<MenuBar<E, A, C>>,
|
||||
status_bar: Option<Box<dyn Widget<Engine = E>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
app,
|
||||
cursor: (0, 0),
|
||||
entered: false,
|
||||
history: vec![],
|
||||
size: Measure::new(),
|
||||
model: model.clone(),
|
||||
view,
|
||||
audio,
|
||||
menu_bar,
|
||||
status_bar,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M, V, C, A, S> Content for AppContainer<Tui, M, V, C, A, S>
|
||||
impl<A, C> Content for AppView<Tui, A, C>
|
||||
where
|
||||
M: Send + Sync,
|
||||
V: Widget<Engine = Tui> + Handle<Tui>,
|
||||
C: Command<V>,
|
||||
A: Audio,
|
||||
S: StatusBar<Tui>,
|
||||
A: Widget<Engine = Tui> + Handle<Tui> + Audio,
|
||||
C: Command<A>
|
||||
{
|
||||
type Engine = Tui;
|
||||
fn content (&self) -> impl Widget<Engine = Tui> {
|
||||
|
|
@ -124,14 +109,14 @@ where
|
|||
Split::up(
|
||||
if self.status_bar.is_some() { 1 } else { 0 },
|
||||
widget(&self.status_bar),
|
||||
widget(&self.view)
|
||||
widget(&self.app)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum AppContainerCommand<T: std::fmt::Debug + Copy + Clone> {
|
||||
pub enum AppViewCommand<T: std::fmt::Debug + Copy + Clone> {
|
||||
Focus(FocusCommand),
|
||||
Undo,
|
||||
Redo,
|
||||
|
|
@ -139,20 +124,17 @@ pub enum AppContainerCommand<T: std::fmt::Debug + Copy + Clone> {
|
|||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub enum AppContainerFocus<F: std::fmt::Debug + Copy + Clone + PartialEq> {
|
||||
pub enum AppViewFocus<F: std::fmt::Debug + Copy + Clone + PartialEq> {
|
||||
Menu,
|
||||
Content(F),
|
||||
}
|
||||
|
||||
impl<T, U, C, A, S> FocusGrid for AppContainer<Tui, T, U, C, A, S>
|
||||
impl<A, C> FocusGrid for AppView<Tui, A, C>
|
||||
where
|
||||
T: Send + Sync,
|
||||
U: From<Arc<RwLock<T>>> + Widget<Engine = Tui> + Handle<Tui> + FocusGrid,
|
||||
C: Command<U>,
|
||||
A: From<Arc<RwLock<T>>> + Audio,
|
||||
S: From<Arc<RwLock<T>>> + StatusBar<Tui>
|
||||
A: Widget<Engine = Tui> + Handle<Tui> + Audio + FocusGrid,
|
||||
C: Command<A>
|
||||
{
|
||||
type Item = AppContainerFocus<<U as FocusGrid>::Item>;
|
||||
type Item = AppViewFocus<<A as FocusGrid>::Item>;
|
||||
fn cursor (&self) -> (usize, usize) {
|
||||
self.cursor
|
||||
}
|
||||
|
|
@ -181,9 +163,9 @@ where
|
|||
}
|
||||
fn layout (&self) -> &[&[Self::Item]] {
|
||||
&[
|
||||
&[AppContainerFocus::Menu],
|
||||
&[AppViewFocus::Menu],
|
||||
FocusGrid::layout(&self.ui)
|
||||
//&[AppContainerFocus::Content(())],
|
||||
//&[AppViewFocus::Content(())],
|
||||
]
|
||||
}
|
||||
fn update_focus (&mut self) {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
use crate::*;
|
||||
|
||||
pub type ArrangerApp = AppContainer<
|
||||
Tui,
|
||||
ArrangerModel,
|
||||
ArrangerView<Tui>,
|
||||
ArrangerViewCommand,
|
||||
ArrangerAudio,
|
||||
ArrangerStatusBar
|
||||
>;
|
||||
pub type ArrangerApp<E: Engine> = AppView<E, ArrangerView<E>, ArrangerViewCommand>;
|
||||
|
||||
impl TryFrom<&Arc<RwLock<JackClient>>> for ArrangerApp {
|
||||
impl TryFrom<&Arc<RwLock<JackClient>>> for ArrangerApp<Tui> {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
fn try_from (jack: &Arc<RwLock<JackClient>>) -> Usually<Self> {
|
||||
let model = Arc::new(RwLock::new(ArrangerModel {
|
||||
Ok(Self::new(ArrangerModel {
|
||||
name: Arc::new(RwLock::new(String::new())),
|
||||
phrases: vec![],
|
||||
scenes: vec![],
|
||||
|
|
@ -23,22 +16,15 @@ impl TryFrom<&Arc<RwLock<JackClient>>> for ArrangerApp {
|
|||
clock: Arc::new(Clock::from(Instant::default())),
|
||||
jack: jack.clone(),
|
||||
},
|
||||
}));
|
||||
Ok(Self::new(
|
||||
&model,
|
||||
ArrangerView::from(&model),
|
||||
ArrangerAudio(model.clone()),
|
||||
None,
|
||||
None
|
||||
))
|
||||
}.into(), None, None))
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Engine> From<&Arc<RwLock<ArrangerModel>>> for ArrangerView<E> {
|
||||
fn from (model: &Arc<RwLock<ArrangerModel>>) -> Self {
|
||||
impl<E: Engine> From<ArrangerModel> for ArrangerView<E> {
|
||||
fn from (model: ArrangerModel) -> Self {
|
||||
let mut view = Self {
|
||||
model: model.clone(),
|
||||
sequencer: SequencerView::from(&model.read().unwrap().sequencer),
|
||||
model,
|
||||
sequencer: SequencerView::from(&model.sequencer),
|
||||
split: 20,
|
||||
selected: ArrangerFocus::Clip(0, 0),
|
||||
mode: ArrangerMode::Vertical(2),
|
||||
|
|
@ -96,31 +82,6 @@ impl ArrangerMode {
|
|||
}
|
||||
}
|
||||
|
||||
impl<E: Engine> Audio for ArrangerView<E> {
|
||||
#[inline] fn process (&mut self, _: &Client, _: &ProcessScope) -> Control {
|
||||
// FIXME: one of these per playing track
|
||||
if let ArrangerFocus::Clip(t, s) = self.selected {
|
||||
let phrase = self.model.scenes().get(s).map(|scene|scene.clips.get(t));
|
||||
if let Some(Some(Some(phrase))) = phrase {
|
||||
if let Some(track) = self.model.tracks().get(t) {
|
||||
if let Some((ref started_at, Some(ref playing))) = track.player.phrase {
|
||||
let phrase = phrase.read().unwrap();
|
||||
if *playing.read().unwrap() == *phrase {
|
||||
let pulse = self.sequencer.transport.model.clock().current.pulse.get();
|
||||
let start = started_at.pulse.get();
|
||||
let now = (pulse - start) % phrase.length as f64;
|
||||
self.sequencer.editor.now.set(now);
|
||||
return Control::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sequencer.editor.now.set(0.);
|
||||
return Control::Continue
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout for standalone arranger app.
|
||||
impl Content for ArrangerView<Tui> {
|
||||
type Engine = Tui;
|
||||
|
|
@ -172,11 +133,13 @@ impl<E: Engine> ArrangerView<E> {
|
|||
}
|
||||
|
||||
pub fn activate (&mut self) {
|
||||
let scenes = self.model.scenes();
|
||||
let tracks = self.model.tracks_mut();
|
||||
match self.selected {
|
||||
ArrangerFocus::Scene(s) => {
|
||||
for (t, track) in self.model.tracks_mut().iter_mut().enumerate() {
|
||||
for (t, track) in tracks.iter_mut().enumerate() {
|
||||
let player = &mut track.player;
|
||||
let clip = self.model.scenes()[s].clips[t].as_ref();
|
||||
let clip = scenes[s].clips[t].as_ref();
|
||||
if player.phrase.is_some() || clip.is_some() {
|
||||
player.enqueue_next(clip);
|
||||
}
|
||||
|
|
@ -188,8 +151,7 @@ impl<E: Engine> ArrangerView<E> {
|
|||
//}
|
||||
},
|
||||
ArrangerFocus::Clip(t, s) => {
|
||||
let clip = self.model.scenes()[s].clips[t].as_ref();
|
||||
self.model.tracks_mut()[t].player.enqueue_next(clip);
|
||||
tracks[t].player.enqueue_next(scenes[s].clips[t]);
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -221,7 +183,7 @@ impl<E: Engine> ArrangerView<E> {
|
|||
self.color = ItemColor::random_dark()
|
||||
},
|
||||
ArrangerFocus::Track(t) => {
|
||||
self.model.tracks_mu()[t].color = ItemColor::random()
|
||||
self.model.tracks_mut()[t].color = ItemColor::random()
|
||||
},
|
||||
ArrangerFocus::Scene(s) => {
|
||||
self.model.scenes_mut()[s].color = ItemColor::random()
|
||||
|
|
@ -243,3 +205,31 @@ impl<E: Engine> ArrangerView<E> {
|
|||
self.selected_scene()?.clips.get(self.selected.track()?)?.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Audio for ArrangerView<Tui> {
|
||||
#[inline] fn process (&mut self, client: &Client, scope: &ProcessScope) -> Control {
|
||||
if self.model.process(client, scope) == Control::Quit {
|
||||
return Control::Quit
|
||||
}
|
||||
// FIXME: one of these per playing track
|
||||
if let ArrangerFocus::Clip(t, s) = self.selected {
|
||||
let phrase = self.model.scenes().get(s).map(|scene|scene.clips.get(t));
|
||||
if let Some(Some(Some(phrase))) = phrase {
|
||||
if let Some(track) = self.model.tracks().get(t) {
|
||||
if let Some((ref started_at, Some(ref playing))) = track.player.phrase {
|
||||
let phrase = phrase.read().unwrap();
|
||||
if *playing.read().unwrap() == *phrase {
|
||||
let pulse = self.sequencer.transport.model.clock().current.pulse.get();
|
||||
let start = started_at.pulse.get();
|
||||
let now = (pulse - start) % phrase.length as f64;
|
||||
self.sequencer.editor.now.set(now);
|
||||
return Control::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sequencer.editor.now.set(0.);
|
||||
return Control::Continue
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ use crate::*;
|
|||
|
||||
pub struct PhrasePoolView<E: Engine> {
|
||||
_engine: PhantomData<E>,
|
||||
pub model: PhrasePool,
|
||||
/// Collection of phrases
|
||||
pub model: Vec<Arc<RwLock<Phrase>>>,
|
||||
/// Selected phrase
|
||||
pub phrase: usize,
|
||||
/// Scroll offset
|
||||
|
|
@ -24,7 +25,7 @@ pub enum PhrasePoolMode {
|
|||
}
|
||||
|
||||
impl<E: Engine> PhrasePoolView<E> {
|
||||
pub fn new (model: PhrasePool) -> Self {
|
||||
pub fn new (model: Vec<Arc<RwLock<Phrase>>>) -> Self {
|
||||
Self {
|
||||
_engine: Default::default(),
|
||||
scroll: 0,
|
||||
|
|
|
|||
|
|
@ -1,64 +1,39 @@
|
|||
use crate::*;
|
||||
|
||||
pub type SequencerApp = AppContainer<
|
||||
Tui,
|
||||
SequencerModel,
|
||||
SequencerView<Tui>,
|
||||
SequencerViewCommand,
|
||||
SequencerAudio,
|
||||
SequencerStatusBar
|
||||
>;
|
||||
pub type SequencerApp<E: Engine> = AppView<E, SequencerView<E>, SequencerViewCommand>;
|
||||
|
||||
impl TryFrom<&Arc<RwLock<JackClient>>> for SequencerApp {
|
||||
impl TryFrom<&Arc<RwLock<JackClient>>> for SequencerApp<Tui> {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
fn try_from (jack: &Arc<RwLock<JackClient>>) -> Usually<Self> {
|
||||
let clock = Arc::new(Clock::from(Instant::default()));
|
||||
|
||||
let transport = Arc::new(RwLock::new(tek_api::Transport {
|
||||
metronome: false,
|
||||
transport: jack.read().unwrap().transport(),
|
||||
jack: jack.clone(),
|
||||
clock: clock.clone()
|
||||
}));
|
||||
|
||||
let phrases = Arc::new(RwLock::new(PhrasePool {
|
||||
phrases: vec![] // FIXME
|
||||
}));
|
||||
|
||||
let player = Arc::new(RwLock::new(MIDIPlayer::new(jack, &clock, "preview")?));
|
||||
|
||||
let model = Arc::new(RwLock::new(SequencerModel {
|
||||
transport: transport.clone(),
|
||||
phrases: phrases.clone(),
|
||||
player: player.clone()
|
||||
}));
|
||||
|
||||
Ok(Self::new(
|
||||
&model,
|
||||
SequencerView::from(&model),
|
||||
SequencerAudio(transport.clone(), player.clone()),
|
||||
None,
|
||||
None,
|
||||
))
|
||||
Ok(Self::new(SequencerModel {
|
||||
phrases: vec![],
|
||||
player: MIDIPlayer::new(jack, &clock, "preview")?,
|
||||
transport: TransportModel {
|
||||
metronome: false,
|
||||
transport: jack.read().unwrap().transport(),
|
||||
jack: jack.clone(),
|
||||
clock: clock.clone()
|
||||
},
|
||||
}.into(), None, None))
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Engine> From<&Arc<RwLock<SequencerModel>>> for SequencerView<E> {
|
||||
fn from (model: &Arc<RwLock<SequencerModel>>) -> Self {
|
||||
impl<E: Engine> From<SequencerModel> for SequencerView<E> {
|
||||
fn from (model: SequencerModel) -> Self {
|
||||
Self {
|
||||
split: 20,
|
||||
transport: TransportView::from(&model.read().unwrap().transport),
|
||||
phrases: PhrasePoolView::from(&model.read().unwrap().phrases),
|
||||
transport: TransportView::from(&model.transport),
|
||||
phrases: PhrasePoolView::from(&model.phrases),
|
||||
editor: PhraseEditor::new(),
|
||||
model: model.clone(),
|
||||
model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Root level object for standalone `tek_sequencer`.
|
||||
/// Also embeddable, in which case the `player` is used for preview.
|
||||
pub struct SequencerView<E: Engine> {
|
||||
pub model: Arc<RwLock<SequencerModel>>,
|
||||
pub model: SequencerModel,
|
||||
/// Displays the JACK transport.
|
||||
pub transport: TransportView<E>,
|
||||
/// Displays the phrase pool
|
||||
|
|
@ -74,7 +49,16 @@ impl Content for SequencerView<Tui> {
|
|||
fn content (&self) -> impl Widget<Engine = Tui> {
|
||||
col!(
|
||||
self.transport,
|
||||
Split::right(20, widget(&self.phrases), widget(&self.editor)).min_y(20)
|
||||
Split::right(20,
|
||||
widget(&self.phrases),
|
||||
widget(&self.editor)
|
||||
).min_y(20)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Audio for SequencerView<Tui> {
|
||||
fn process (&mut self, client: &Client, scope: &ProcessScope) -> Control {
|
||||
self.model.process(client, scope)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,16 @@
|
|||
use crate::*;
|
||||
|
||||
pub type TransportApp = AppContainer<
|
||||
Tui,
|
||||
Transport,
|
||||
TransportView<Tui>,
|
||||
TransportViewCommand,
|
||||
TransportAudio,
|
||||
TransportStatusBar
|
||||
>;
|
||||
pub type TransportApp<E: Engine> = AppView<E, TransportView<E>, TransportViewCommand>;
|
||||
|
||||
impl TryFrom<&Arc<RwLock<JackClient>>> for TransportApp {
|
||||
impl TryFrom<&Arc<RwLock<JackClient>>> for TransportApp<Tui> {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
fn try_from (jack: &Arc<RwLock<JackClient>>) -> Usually<Self> {
|
||||
let model = Arc::new(RwLock::new(TransportModel {
|
||||
metronome: false,
|
||||
transport: jack.read().unwrap().transport(),
|
||||
jack: jack.clone(),
|
||||
clock: Arc::new(Clock::from(Instant::default()))
|
||||
}));
|
||||
Ok(Self::new(
|
||||
&model,
|
||||
TransportView::from(&model),
|
||||
TransportAudio(model.clone()),
|
||||
None,
|
||||
None,
|
||||
))
|
||||
Ok(Self::new(TransportModel {
|
||||
metronome: false,
|
||||
transport: jack.read().unwrap().transport(),
|
||||
jack: jack.clone(),
|
||||
clock: Arc::new(Clock::from(Instant::default()))
|
||||
}.into(), None, None))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,42 +24,14 @@ pub struct TransportView<E: Engine> {
|
|||
pub size: Measure<E>,
|
||||
}
|
||||
|
||||
impl<E: Engine> From<&Arc<RwLock<Transport>>> for TransportView<E> {
|
||||
fn from (model: &Arc<RwLock<Transport>>) -> Self {
|
||||
impl<E: Engine> From<TransportModel> for TransportView<E> {
|
||||
fn from (model: TransportModel) -> Self {
|
||||
Self {
|
||||
_engine: Default::default(),
|
||||
focused: false,
|
||||
focus: TransportViewFocus::PlayPause,
|
||||
size: Measure::new(),
|
||||
model: model.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Engine> TransportView<E> {
|
||||
pub fn new (jack: &Arc<RwLock<JackClient>>, clock: Option<&Arc<Clock>>) -> Self {
|
||||
Self {
|
||||
_engine: Default::default(),
|
||||
focused: false,
|
||||
focus: TransportViewFocus::PlayPause,
|
||||
size: Measure::new(),
|
||||
model: Transport {
|
||||
metronome: false,
|
||||
transport: jack.read().unwrap().transport(),
|
||||
jack: jack.clone(),
|
||||
clock: if let Some(clock) = clock {
|
||||
clock.clone()
|
||||
} else {
|
||||
let current = Instant::default();
|
||||
Arc::new(Clock {
|
||||
playing: Some(TransportState::Stopped).into(),
|
||||
started: None.into(),
|
||||
quant: 24.into(),
|
||||
sync: (current.timebase.ppq.get() * 4.).into(),
|
||||
current,
|
||||
})
|
||||
}
|
||||
}
|
||||
model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -84,7 +42,7 @@ impl Content for TransportView<Tui> {
|
|||
lay!(
|
||||
self.focus.wrap(self.focused, TransportViewFocus::PlayPause, &Styled(
|
||||
None,
|
||||
match *self.model.clock.playing.read().unwrap() {
|
||||
match *self.model.clock().playing.read().unwrap() {
|
||||
Some(TransportState::Rolling) => "▶ PLAYING",
|
||||
Some(TransportState::Starting) => "READY ...",
|
||||
Some(TransportState::Stopped) => "⏹ STOPPED",
|
||||
|
|
@ -94,23 +52,29 @@ impl Content for TransportView<Tui> {
|
|||
|
||||
row!(
|
||||
self.focus.wrap(self.focused, TransportViewFocus::Bpm, &Outset::X(1u16, {
|
||||
let bpm = self.model.clock.timebase().bpm.get();
|
||||
let bpm = self.model.clock().timebase().bpm.get();
|
||||
row! { "BPM ", format!("{}.{:03}", bpm as usize, (bpm * 1000.0) % 1000.0) }
|
||||
})),
|
||||
//let quant = self.focus.wrap(self.focused, TransportViewFocus::Quant, &Outset::X(1u16, row! {
|
||||
//"QUANT ", ppq_to_name(self.quant as usize)
|
||||
//})),
|
||||
self.focus.wrap(self.focused, TransportViewFocus::Sync, &Outset::X(1u16, row! {
|
||||
"SYNC ", pulses_to_name(self.model.clock.sync.get() as usize)
|
||||
"SYNC ", pulses_to_name(self.model.clock().sync.get() as usize)
|
||||
}))
|
||||
).align_w().fill_x(),
|
||||
|
||||
self.focus.wrap(self.focused, TransportViewFocus::Clock, &{
|
||||
let time1 = self.model.clock.current.format_beat();
|
||||
let time2 = self.model.clock.current.usec.format_msu();
|
||||
let time1 = self.model.clock().current.format_beat();
|
||||
let time2 = self.model.clock().current.usec.format_msu();
|
||||
row!("B" ,time1.as_str(), " T", time2.as_str()).outset_x(1)
|
||||
}).align_e().fill_x(),
|
||||
|
||||
).fill_x().bg(Color::Rgb(40, 50, 30))
|
||||
}
|
||||
}
|
||||
|
||||
impl Audio for TransportView<Tui> {
|
||||
fn process (&mut self, client: &Client, scope: &ProcessScope) -> Control {
|
||||
self.model.process(client, scope)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue