mirror of
https://codeberg.org/unspeaker/tek.git
synced 2026-10-08 18:56:42 +02:00
rework command/namespace dispatch
This commit is contained in:
parent
def7a1b210
commit
406aabde6e
15 changed files with 586 additions and 374 deletions
406
src/tek.rs
406
src/tek.rs
|
|
@ -620,7 +620,7 @@ mod app {
|
|||
/// ```
|
||||
/// use tek::{HasTracks, HasScenes, TracksView, ScenesView};
|
||||
/// let mut app = tek::App::default();
|
||||
/// let _ = app.scene_add(None, None).unwrap();
|
||||
/// let _ = app.scenes_add_one(None, None).unwrap();
|
||||
/// let _ = app.update_clock();
|
||||
/// app.project.editor = Some(Default::default());
|
||||
/// //let _: Vec<_> = app.project.inputs_with_sizes().collect();
|
||||
|
|
@ -639,21 +639,30 @@ mod app {
|
|||
/// let _ = app.project.h_scenes();
|
||||
/// ```
|
||||
#[derive(Default, Debug)]
|
||||
#[namespace(u8)]
|
||||
#[namespace(isize)]
|
||||
#[namespace(ItemTheme)]
|
||||
#[namespace(Arc<[Connect]> App::get_arc_array_connect)]
|
||||
#[namespace(Arc<str> App::get_arc_str)]
|
||||
#[namespace(u16 App::get_u16)]
|
||||
#[namespace(usize App::get_usize)]
|
||||
#[namespace(bool App::get_bool)]
|
||||
#[namespace(Selection App::get_selection)]
|
||||
#[namespace(Color App::get_color)]
|
||||
#[namespace(Option<u7> App::get_opt_u7)]
|
||||
#[namespace(Option<u16> App::get_opt_u16)]
|
||||
#[namespace(Option<usize> App::get_opt_usize)]
|
||||
#[namespace(Option<Arc<RwLock<MidiClip>>> App::get_clip)]
|
||||
#[namespace(Dialog App::get_dialog)]
|
||||
#[namespace(ControlAxis App::get_axis)]
|
||||
#[namespace(Dialog App::get_dialog)]
|
||||
#[namespace(MidiClip)]
|
||||
#[namespace(ItemColor)]
|
||||
#[namespace(ItemTheme)]
|
||||
#[namespace(Option<Arc<RwLock<MidiClip>>> App::get_clip)]
|
||||
#[namespace(Option<Arc<[Connect]>> App::get_opt_arc_array_connect)]
|
||||
#[namespace(Option<Arc<str>> App::get_opt_arc_str)]
|
||||
#[namespace(Option<ItemTheme> App::get_opt_itemtheme)]
|
||||
#[namespace(Option<Vec<Option<Arc<RwLock<MidiClip>>>>> App::get_opt_vec_opt_arc_rwlock_midiclip)]
|
||||
#[namespace(Option<u16> App::get_opt_u16)]
|
||||
#[namespace(Option<u7> App::get_opt_u7)]
|
||||
#[namespace(Option<usize> App::get_opt_usize)]
|
||||
#[namespace(Option<bool> App::get_opt_bool)]
|
||||
#[namespace(Selection App::get_selection)]
|
||||
#[namespace(bool App::get_bool)]
|
||||
#[namespace(u16 App::get_u16)]
|
||||
#[namespace(PathBuf App::get_path_buf)]
|
||||
#[namespace(isize)]
|
||||
#[namespace(u8)]
|
||||
#[namespace(usize App::get_usize)]
|
||||
pub struct App {
|
||||
/// Exit flag
|
||||
pub exit: Exit,
|
||||
|
|
@ -709,6 +718,10 @@ mod app {
|
|||
}
|
||||
}
|
||||
|
||||
fn get_path_buf (&self, src: impl Language) -> Perhaps<PathBuf> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_arc_str (&self, src: impl Language) -> Perhaps<Arc<str>> {
|
||||
Ok(src.src()?.map(|x|x.into()))
|
||||
}
|
||||
|
|
@ -827,12 +840,34 @@ mod app {
|
|||
|
||||
fn get_axis (&self, src: impl Language) -> Perhaps<ControlAxis> {
|
||||
Ok(src.word()?.map(|word|Ok(match word {
|
||||
"x" => ControlAxis::X,
|
||||
"y" => ControlAxis::Y,
|
||||
":x" => ControlAxis::X,
|
||||
":y" => ControlAxis::Y,
|
||||
_ => return Err(format!("unknown axis {word}"))
|
||||
})).transpose()?)
|
||||
}
|
||||
|
||||
fn get_opt_itemtheme (&self, src: impl Language) -> Perhaps<Option<ItemTheme>> {
|
||||
todo!()
|
||||
}
|
||||
fn get_opt_vec_opt_arc_rwlock_midiclip (&self, src: impl Language) -> Perhaps<Option<Vec<Option<Arc<RwLock<MidiClip>>>>>> {
|
||||
todo!()
|
||||
}
|
||||
fn get_opt_arc_array_connect (&self, src: impl Language) -> Perhaps<Option<Arc<[Connect]>>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_arc_array_connect (&self, src: impl Language) -> Perhaps<Arc<[Connect]>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_opt_arc_str (&self, src: impl Language) -> Perhaps<Option<Arc<str>>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_opt_bool (&self, src: impl Language) -> Perhaps<Option<bool>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -879,56 +914,171 @@ mod bind {
|
|||
/// let condition = tek::Condition(std::sync::Arc::new(Box::new(||{true})));
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct Condition(
|
||||
pub Arc<Box<dyn Fn()->bool + Send + Sync>>
|
||||
);
|
||||
pub struct Condition(pub Arc<Box<dyn Fn()->bool + Send + Sync>>);
|
||||
|
||||
tui_keys!(self: App, input {
|
||||
let commands = collect_commands(self, input)?;
|
||||
let results = execute_commands(self, commands)?;
|
||||
self.history.extend(results.into_iter());
|
||||
Ok(())
|
||||
});
|
||||
|
||||
fn collect_commands (app: &App, input: &TuiEvent)
|
||||
-> Usually<Vec<AppCommand>>
|
||||
{
|
||||
let mut commands = vec![];
|
||||
app.mode.as_ref().and_then(|m|app.config.get_mode(m)).map(|mode|{
|
||||
let name = self.mode.as_ref();
|
||||
let mode = name.and_then(|m|self.config.get_mode(m)).as_ref().map(Arc::clone);
|
||||
if let Some(mode) = mode {
|
||||
let binds = self.config.binds.clone();
|
||||
for id in mode.keys.iter() {
|
||||
if let Some(event_map) = app.config.binds.clone().read().unwrap().get(id.as_ref())
|
||||
if let Some(event_map) = binds.read().unwrap().get(id.as_ref())
|
||||
&& let Some(bindings) = event_map.query(input) {
|
||||
for binding in bindings {
|
||||
for command in binding.commands.iter() {
|
||||
if let Some(command) = app.namespace(command)? as Option<AppCommand> {
|
||||
commands.push(command)
|
||||
if Self::command::<AppCommand>(self, command)? {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<_, Box<dyn Error>>(())
|
||||
}).transpose()?;
|
||||
Ok(commands)
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
impl App {
|
||||
fn command <
|
||||
T: Dispatch<Self> + Clone + Into<AppCommand>
|
||||
> (state: &mut Self, src: &str) -> Usually<bool> where
|
||||
Self: Namespace<T>,
|
||||
{
|
||||
if let Some(command) = Namespace::<T>::namespace(state, src)? {
|
||||
match command.clone().dispatch(state) {
|
||||
Err(err) => {
|
||||
state.history.push((command.into(), None));
|
||||
return Err(err)
|
||||
},
|
||||
Ok(undo) => {
|
||||
state.history.push((command.into(), undo.map(Into::into)));
|
||||
return Ok(true)
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_commands (app: &mut App, commands: Vec<AppCommand>)
|
||||
-> Usually<Vec<(AppCommand, Option<AppCommand>)>>
|
||||
{
|
||||
let mut history = vec![];
|
||||
for command in commands.into_iter() {
|
||||
let result = command.clone().act(app);
|
||||
match result {
|
||||
Err(err) => {
|
||||
history.push((command, None));
|
||||
return Err(err)
|
||||
},
|
||||
Ok(undo) => {
|
||||
history.push((command, undo));
|
||||
}
|
||||
};
|
||||
//#[derive(Clone, Debug)]
|
||||
//pub enum AppCommand {
|
||||
//Axis(AxisCommand),
|
||||
//Browse(BrowseCommand),
|
||||
//Dialog(DialogCommand),
|
||||
//MidiClip(MidiClipCommand),
|
||||
//Pool(PoolCommand),
|
||||
//Scene(SceneCommand),
|
||||
//Scenes(ScenesCommand),
|
||||
//Track(TrackCommand),
|
||||
//Tracks(TracksCommand),
|
||||
//}
|
||||
|
||||
impl_from!(AppCommand: |x: AxisCommand| AppCommand::Axis { command: x });
|
||||
impl_from!(AppCommand: |x: BrowseCommand| AppCommand::Browse { command: x });
|
||||
impl_from!(AppCommand: |x: DialogCommand| AppCommand::Dialog { command: x });
|
||||
impl_from!(AppCommand: |x: MidiClipCommand| AppCommand::MidiClip { command: x });
|
||||
impl_from!(AppCommand: |x: PoolCommand| AppCommand::Pool { command: x });
|
||||
impl_from!(AppCommand: |x: SceneCommand| AppCommand::Scene { command: x });
|
||||
impl_from!(AppCommand: |x: ScenesCommand| AppCommand::Scenes { command: x });
|
||||
impl_from!(AppCommand: |x: TrackCommand| AppCommand::Track { command: x });
|
||||
impl_from!(AppCommand: |x: TracksCommand| AppCommand::Tracks { command: x });
|
||||
#[tek_proc::commands(AppCommand)]
|
||||
impl App {
|
||||
#[command(Axis = "axis")]
|
||||
pub fn command_axis (&mut self, command: AxisCommand) -> Perhaps<AppCommand> {
|
||||
Ok(command.dispatch(self)?.map(Into::into))
|
||||
}
|
||||
#[command(Browse = "browse")]
|
||||
pub fn command_browse (&mut self, command: BrowseCommand) -> Perhaps<AppCommand> {
|
||||
Ok(command.dispatch(self)?.map(Into::into))
|
||||
}
|
||||
#[command(Dialog = "dialog")]
|
||||
pub fn command_dialog (&mut self, command: DialogCommand) -> Perhaps<AppCommand> {
|
||||
Ok(command.dispatch(self)?.map(Into::into))
|
||||
}
|
||||
#[command(MidiClip = "clip")]
|
||||
pub fn command_midi_clip (&mut self, command: MidiClipCommand) -> Perhaps<AppCommand> {
|
||||
Ok(command.dispatch(self)?.map(Into::into))
|
||||
}
|
||||
#[command(Pool = "pool")]
|
||||
pub fn command_pool (&mut self, command: PoolCommand) -> Perhaps<AppCommand> {
|
||||
Ok(command.dispatch(self)?.map(Into::into))
|
||||
}
|
||||
#[command(Scene = "scene")]
|
||||
pub fn command_scene (&mut self, command: SceneCommand) -> Perhaps<AppCommand> {
|
||||
Ok(command.dispatch(self)?.map(Into::into))
|
||||
}
|
||||
#[command(Scenes = "scenes")]
|
||||
pub fn command_scenes (&mut self, command: ScenesCommand) -> Perhaps<AppCommand> {
|
||||
Ok(command.dispatch(self)?.map(Into::into))
|
||||
}
|
||||
#[command(Track = "track")]
|
||||
pub fn command_track (&mut self, command: TrackCommand) -> Perhaps<AppCommand> {
|
||||
Ok(command.dispatch(self)?.map(Into::into))
|
||||
}
|
||||
#[command(Tracks = "tracks")]
|
||||
pub fn command_tracks (&mut self, command: TracksCommand) -> Perhaps<AppCommand> {
|
||||
Ok(command.dispatch(self)?.map(Into::into))
|
||||
}
|
||||
}
|
||||
|
||||
#[tek_proc::commands(DialogCommand)]
|
||||
impl App {
|
||||
/// Cancel current dialog
|
||||
#[command(Cancel = "cancel")]
|
||||
pub fn cancel (&mut self) -> Perhaps<DialogCommand> {
|
||||
todo!()
|
||||
}
|
||||
/// Confirm current dialog selection.
|
||||
#[command(Confirm = "confirm")]
|
||||
pub fn confirm (&mut self) -> Perhaps<DialogCommand> {
|
||||
Ok(match &self.dialog {
|
||||
Dialog::Menu(index, items) => {
|
||||
let callback = items.0[*index].1.clone();
|
||||
callback(self)?;
|
||||
None
|
||||
},
|
||||
_ => todo!(),
|
||||
})
|
||||
}
|
||||
/// Swap currently active modal dialog.
|
||||
///
|
||||
/// ```
|
||||
/// let _ = tek::App::default().set_dialog(tek::Dialog::welcome());
|
||||
/// ```
|
||||
#[command(SetDialog = "seg")]
|
||||
pub fn set_dialog (&mut self, dialog: Dialog) -> Perhaps<DialogCommand> {
|
||||
let mut dialog = dialog.clone();
|
||||
std::mem::swap(&mut self.dialog, &mut dialog);
|
||||
Ok(Some(DialogCommand::SetDialog { dialog }))
|
||||
}
|
||||
}
|
||||
|
||||
#[tek_proc::commands(AxisCommand)]
|
||||
impl App {
|
||||
/// Increment a given data axis.
|
||||
#[command(Inc = "inc")]
|
||||
pub fn inc (&mut self, axis: ControlAxis) -> Perhaps<AxisCommand> {
|
||||
Ok(match (&self.dialog, axis) {
|
||||
(Dialog::None, _) => todo!(),
|
||||
(Dialog::Menu(_, _), ControlAxis::Y) => {
|
||||
DialogCommand::SetDialog { dialog: self.dialog.menu_next() }.dispatch(self)?;
|
||||
None
|
||||
},
|
||||
_ => todo!()
|
||||
})
|
||||
}
|
||||
/// Decrement a given data axis.
|
||||
#[command(Dec = "dec")]
|
||||
pub fn dec (&mut self, axis: ControlAxis) -> Perhaps<AxisCommand> {
|
||||
Ok(match (&self.dialog, axis) {
|
||||
(Dialog::None, _) => None,
|
||||
(Dialog::Menu(_, _), ControlAxis::Y) => {
|
||||
DialogCommand::SetDialog { dialog: self.dialog.menu_prev() }.dispatch(self)?;
|
||||
None
|
||||
},
|
||||
_ => todo!()
|
||||
})
|
||||
}
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
pub(crate) fn load_bind (binds: &Binds, name: &impl AsRef<str>, body: &impl Language) -> Usually<()> {
|
||||
|
|
@ -1012,75 +1162,6 @@ mod bind {
|
|||
|
||||
impl_debug!(Condition |self, w| { write!(w, "*") });
|
||||
|
||||
#[tek_proc::commands(AppCommand = "app")]
|
||||
impl App {
|
||||
|
||||
#[command(Nop = "nop")]
|
||||
pub fn nop (&mut self) -> Perhaps<AppCommand> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[command(Cancel = "cancel")]
|
||||
pub fn cancel (&mut self) -> Perhaps<AppCommand> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[command(Inc = "inc")]
|
||||
pub fn inc (&mut self, axis: ControlAxis) -> Perhaps<AppCommand> {
|
||||
Ok(match (&self.dialog, axis) {
|
||||
(Dialog::None, _) => todo!(),
|
||||
(Dialog::Menu(_, _), ControlAxis::Y) =>
|
||||
AppCommand::SetDialog { dialog: self.dialog.menu_next() }.act(self)?,
|
||||
_ => todo!()
|
||||
})
|
||||
}
|
||||
|
||||
#[command(Dec = "dec")]
|
||||
pub fn dec (&mut self, axis: ControlAxis) -> Perhaps<AppCommand> {
|
||||
Ok(match (&self.dialog, axis) {
|
||||
(Dialog::None, _) => None,
|
||||
(Dialog::Menu(_, _), ControlAxis::Y) =>
|
||||
AppCommand::SetDialog { dialog: self.dialog.menu_prev() }.act(self)?,
|
||||
_ => todo!()
|
||||
})
|
||||
}
|
||||
|
||||
#[command(Confirm = "confirm")]
|
||||
pub fn confirm (&mut self) -> Perhaps<AppCommand> {
|
||||
Ok(match &self.dialog {
|
||||
Dialog::Menu(index, items) => {
|
||||
let callback = items.0[*index].1.clone();
|
||||
callback(self)?;
|
||||
None
|
||||
},
|
||||
_ => todo!(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Swap currently active modal dialog.
|
||||
///
|
||||
/// ```
|
||||
/// let _ = tek::App::default().set_dialog(tek::Dialog::welcome());
|
||||
/// ```
|
||||
#[command(SetDialog = "dialog")]
|
||||
pub fn set_dialog (&mut self, dialog: Dialog) -> Perhaps<AppCommand> {
|
||||
let mut dialog = dialog.clone();
|
||||
std::mem::swap(&mut self.dialog, &mut dialog);
|
||||
Ok(Some(AppCommand::SetDialog { dialog }))
|
||||
}
|
||||
}
|
||||
|
||||
//impl<'a> Namespace<'a, AppCommand> for App {
|
||||
//symbols!('a |app| -> AppCommand {
|
||||
//"x/inc" => AppCommand::Inc { axis: ControlAxis::X },
|
||||
//"x/dec" => AppCommand::Dec { axis: ControlAxis::X },
|
||||
//"y/inc" => AppCommand::Inc { axis: ControlAxis::Y },
|
||||
//"y/dec" => AppCommand::Dec { axis: ControlAxis::Y },
|
||||
//"confirm" => AppCommand::Confirm,
|
||||
//"cancel" => AppCommand::Cancel,
|
||||
//});
|
||||
//}
|
||||
|
||||
/// A control axis.
|
||||
///
|
||||
/// ```
|
||||
|
|
@ -1293,6 +1374,18 @@ mod device {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn view_device (state: &App) -> impl Draw<Tui> {
|
||||
let selected = state.dialog.device_kind().unwrap();
|
||||
south(
|
||||
bold(true, "Add device"),
|
||||
iter_south(move||device_kinds().iter().enumerate().map(move|(i, _label)|{
|
||||
let b = if i == selected { Rgb(64,128,32) } else { Rgb(0,0,0) };
|
||||
let l = if i == selected { "[ " } else { " " };
|
||||
let r = if i == selected { " ]" } else { " " };
|
||||
bg(b, east(l, west(r, "FIXME device name"))).full_w()
|
||||
})))
|
||||
}
|
||||
|
||||
pub mod arrange; pub use self::arrange::*;
|
||||
pub mod browse; pub use self::browse::*;
|
||||
pub mod pool; pub use self::pool::*;
|
||||
|
|
@ -1346,8 +1439,10 @@ mod draw {
|
|||
|
||||
impl Keywords<Tui, XYWH<u16>> for App {
|
||||
fn keywords () -> impl Iterator<Item = fn(&Self, &mut Tui, &str) -> Perhaps<XYWH<u16>>> {
|
||||
[kw_when, kw_either, kw_split, kw_align, kw_exact, kw_min, kw_max, kw_push, kw_full,
|
||||
kw_tui_text, kw_tui_fg, kw_tui_bg]
|
||||
[
|
||||
kw_when, kw_either, kw_split, kw_align, kw_exact, kw_min, kw_max, kw_push, kw_full,
|
||||
kw_tui_text, kw_tui_fg, kw_tui_bg
|
||||
]
|
||||
.into_iter()
|
||||
}
|
||||
}
|
||||
|
|
@ -1521,73 +1616,6 @@ mod draw {
|
|||
Ok(Some(to.area().into()))
|
||||
}).min_w(w).exact_h(h)
|
||||
}
|
||||
|
||||
pub fn view_device (state: &App) -> impl Draw<Tui> {
|
||||
let selected = state.dialog.device_kind().unwrap();
|
||||
south(
|
||||
bold(true, "Add device"),
|
||||
iter_south(move||device_kinds().iter().enumerate().map(move|(i, _label)|{
|
||||
let b = if i == selected { Rgb(64,128,32) } else { Rgb(0,0,0) };
|
||||
let l = if i == selected { "[ " } else { " " };
|
||||
let r = if i == selected { " ]" } else { " " };
|
||||
bg(b, east(l, west(r, "FIXME device name"))).full_w()
|
||||
})))
|
||||
}
|
||||
|
||||
/// ```
|
||||
/// let x = "";
|
||||
/// let _ = tek::view_transport(true, x.as_ref(), x.as_ref(), x.as_ref());
|
||||
/// let _ = tek::view_transport(false, x.as_ref(), x.as_ref(), x.as_ref());
|
||||
/// ```
|
||||
pub fn view_transport (play: bool, bpm: &str, beat: &str, time: &str) -> impl Draw<Tui> {
|
||||
let theme = ItemTheme::G[96];
|
||||
bg(Black, east!(above(
|
||||
button_play_pause(play, false).align_w(),
|
||||
east!(
|
||||
field_h(theme, "BPM", bpm),
|
||||
field_h(theme, "Beat", beat),
|
||||
field_h(theme, "Time", time),
|
||||
).align_e().full_wh()
|
||||
)))
|
||||
}
|
||||
|
||||
/// ```
|
||||
/// let x = "";
|
||||
/// let _ = tek::view_status(None, x.as_ref(), x.as_ref(), x.as_ref());
|
||||
/// let _ = tek::view_status(Some("".into()), x.as_ref(), x.as_ref(), x.as_ref());
|
||||
/// ```
|
||||
pub fn view_status (sel: Option<&str>, sr: &str, buf: &str, lat: &str) -> impl Draw<Tui> {
|
||||
let theme = ItemTheme::G[96];
|
||||
let sr = field_h(theme, "SR", sr);
|
||||
let buf = field_h(theme, "Buf", buf);
|
||||
let lat = field_h(theme, "Lat", lat);
|
||||
bg(Black, east!(above(
|
||||
sel.map(|sel|field_h(theme, "Selected", sel)).align_w().full_wh(),
|
||||
east!(sr, buf, lat).align_e().full_wh(),
|
||||
)))
|
||||
}
|
||||
|
||||
/// ```
|
||||
/// let _ = tek::button_play_pause(true, true);
|
||||
/// let _ = tek::button_play_pause(true, false);
|
||||
/// let _ = tek::button_play_pause(false, true);
|
||||
/// let _ = tek::button_play_pause(false, false);
|
||||
/// ```
|
||||
pub fn button_play_pause (playing: bool, compact: bool) -> impl Draw<Tui> {
|
||||
bg(if playing { Rgb(0, 128, 0) } else { Rgb(128, 64, 0) },
|
||||
either(compact,
|
||||
draw(move|to: &mut Tui|either(playing,
|
||||
fg(Rgb(0, 255, 0), " PLAYING "),
|
||||
fg(Rgb(255, 128, 0), " STOPPED "),
|
||||
).exact_w(9).draw(to)),
|
||||
draw(move|to: &mut Tui|either(playing,
|
||||
fg(Rgb(0, 255, 0), south(" 🭍🭑🬽 ", " 🭞🭜🭘 ",)),
|
||||
fg(Rgb(255, 128, 0), south(" ▗▄▖ ", " ▝▀▘ ",)),
|
||||
).exact_w(5).draw(to)),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// ```
|
||||
/// let bg = tengri::ratatui::style::Color::Red;
|
||||
/// let fg = tengri::ratatui::style::Color::Green;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue