mirror of
https://codeberg.org/unspeaker/tek.git
synced 2025-12-06 11:46:41 +01:00
fixed Map operator!
This commit is contained in:
parent
7ff731133c
commit
38e2e64751
11 changed files with 362 additions and 436 deletions
|
|
@ -12,7 +12,7 @@ pub trait Render<E: Output>: Send + Sync {
|
|||
|
||||
pub type RenderDyn<'a, Output> = dyn Render<Output> + 'a;
|
||||
impl<'a, E: Output> Content<E> for &RenderDyn<'a, E> where Self: Sized {
|
||||
fn content (&self) -> impl Render<E> { *self }
|
||||
fn content (&self) -> impl Render<E> { self.deref() }
|
||||
fn layout (&self, area: E::Area) -> E::Area { Render::layout(self.deref(), area) }
|
||||
fn render (&self, output: &mut E) { Render::render(self.deref(), output) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ impl<E, A, B, I, F, G> Content<E> for Map<A, B, I, F, G> where
|
|||
for item in get_iterator() {
|
||||
let item = callback(item, index);
|
||||
//to.place(area.into(), &item);
|
||||
to.place(to.area().into(), &item);
|
||||
to.place(item.layout(to.area()), &item);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,11 +57,9 @@ transform_xy_unit!(|self: Fixed, area|{
|
|||
transform_xy_unit!(|self: Shrink, area|Render::layout(&self.content(), [
|
||||
area.x(), area.y(), area.w().minus(self.dx()), area.h().minus(self.dy())
|
||||
].into()));
|
||||
|
||||
transform_xy_unit!(|self: Expand, area|Render::layout(&self.content(), [
|
||||
area.x(), area.y(), area.w() + self.dx(), area.h() + self.dy()
|
||||
].into()));
|
||||
|
||||
transform_xy_unit!(|self: Min, area|{
|
||||
let area = Render::layout(&self.content(), area);
|
||||
match self {
|
||||
|
|
@ -69,7 +67,6 @@ transform_xy_unit!(|self: Min, area|{
|
|||
Self::Y(mh, _) => [area.x(), area.y(), area.w(), area.h().max(*mh)],
|
||||
Self::XY(mw, mh, _) => [area.x(), area.y(), area.w().max(*mw), area.h().max(*mh)]
|
||||
}});
|
||||
|
||||
transform_xy_unit!(|self: Max, area|{
|
||||
let area = Render::layout(&self.content(), area);
|
||||
match self {
|
||||
|
|
@ -77,23 +74,20 @@ transform_xy_unit!(|self: Max, area|{
|
|||
Self::Y(mh, _) => [area.x(), area.y(), area.w(), area.h().min(*mh)],
|
||||
Self::XY(mw, mh, _) => [area.x(), area.y(), area.w().min(*mw), area.h().min(*mh)],
|
||||
}});
|
||||
|
||||
transform_xy_unit!(|self: Push, area|Render::layout(&self.content(), [
|
||||
area.x() + self.dx(), area.y() + self.dy(), area.w(), area.h()
|
||||
].into()));
|
||||
|
||||
transform_xy_unit!(|self: Push, area|{
|
||||
let area = Render::layout(&self.content(), area);
|
||||
[area.x() + self.dx(), area.y() + self.dy(), area.w(), area.h()]
|
||||
});
|
||||
transform_xy_unit!(|self: Pull, area|{
|
||||
let area = Render::layout(&self.content(), area);
|
||||
[area.x().minus(self.dx()), area.y().minus(self.dy()), area.w(), area.h()]
|
||||
});
|
||||
|
||||
transform_xy_unit!(|self: Margin, area|{
|
||||
let area = Render::layout(&self.content(), area);
|
||||
let dx = self.dx();
|
||||
let dy = self.dy();
|
||||
[area.x().minus(dx), area.y().minus(dy), area.w() + dy + dy, area.h() + dy + dy]
|
||||
});
|
||||
|
||||
transform_xy_unit!(|self: Padding, area|{
|
||||
let area = Render::layout(&self.content(), area);
|
||||
let dx = self.dx();
|
||||
|
|
|
|||
160
src/arranger.rs
160
src/arranger.rs
|
|
@ -4,13 +4,11 @@ mod arranger_command; pub(crate) use self::arranger_command::*;
|
|||
mod arranger_scene; pub(crate) use self::arranger_scene::*;
|
||||
mod arranger_select; pub(crate) use self::arranger_select::*;
|
||||
mod arranger_track; pub(crate) use self::arranger_track::*;
|
||||
mod arranger_tui; pub(crate) use self::arranger_tui::*;
|
||||
mod arranger_mode; pub(crate) use self::arranger_mode::*;
|
||||
mod arranger_h;
|
||||
mod arranger_v_clips; pub(crate) use self::arranger_v_clips::*;
|
||||
mod arranger_v_cursor; pub(crate) use self::arranger_v_cursor::*;
|
||||
mod arranger_v_sep; pub(crate) use self::arranger_v_sep::*;
|
||||
|
||||
pub(crate) const HEADER_H: u16 = 5;
|
||||
pub(crate) const HEADER_H: u16 = 0; // 5
|
||||
pub(crate) const SCENES_W_OFFSET: u16 = 3;
|
||||
|
||||
/// Root view for standalone `tek_arranger`
|
||||
|
|
@ -98,160 +96,6 @@ from_jack!(|jack| Arranger {
|
|||
compact: false,
|
||||
}
|
||||
});
|
||||
//impl Arranger {
|
||||
//fn render_mode (state: &Self) -> impl Content<TuiOut> + use<'_> {
|
||||
//match state.mode {
|
||||
//ArrangerMode::H => todo!("horizontal arranger"),
|
||||
//ArrangerMode::V(factor) => Self::render_mode_v(state, factor),
|
||||
//}
|
||||
//}
|
||||
//}
|
||||
//render!(TuiOut: (self: Arranger) => {
|
||||
//let pool_w = if self.pool.visible { self.splits[1] } else { 0 };
|
||||
//let color = self.color;
|
||||
//let layout = Bsp::a(Fill::xy(ArrangerStatus::from(self)),
|
||||
//Bsp::n(Fixed::x(pool_w, PoolView(self.pool.visible, &self.pool)),
|
||||
//Bsp::n(TransportView::new(true, &self.clock),
|
||||
//Bsp::s(Fixed::y(1, MidiEditStatus(&self.editor)),
|
||||
//Bsp::n(Fill::x(Fixed::y(20,
|
||||
//Bsp::a(Fill::xy(Tui::bg(color.darkest.rgb, "background")),
|
||||
//Bsp::a(
|
||||
//Fill::xy(Outer(Style::default().fg(color.dark.rgb).bg(color.darkest.rgb))),
|
||||
//Self::render_mode(self))))), Fill::y(&"fixme: self.editor"))))));
|
||||
//self.size.of(layout)
|
||||
//});
|
||||
render!(TuiOut: (self: Arranger) => self.size.of(
|
||||
Bsp::s(self.toolbar_view(),
|
||||
Bsp::n(self.status_view(),
|
||||
Bsp::n(self.selector_view(),
|
||||
Bsp::s(Align::nw(Fill::x(Fixed::y(3, self.header()))),
|
||||
Bsp::s(Align::nw(Fill::x(Fixed::y(1, self.ins()))),
|
||||
Bsp::n(Align::nw(Fill::x(Fixed::y(1, self.outs()))),
|
||||
Bsp::w(self.pool_view(), Fill::xy(lay!(
|
||||
Align::nw(Fill::xy(Tui::bg(self.color.darkest.rgb, " "))),
|
||||
Align::nw(Fill::xy(ArrangerVColSep::from(self))),
|
||||
Align::nw(Fill::xy(ArrangerVRowSep::from((self, 1)))),
|
||||
Align::nw(Fill::xy(ArrangerVCursor::from((self, 1)))),
|
||||
Align::nw(Fill::xy(":")))))))))))));
|
||||
//"todo:"))))))));
|
||||
//Bsp::s(
|
||||
//Align::nw(Fixed::y(1, Fill::x(ArrangerVIns::from(self)))),
|
||||
//Bsp::s(
|
||||
//Fixed::y(20, Align::nw(ArrangerVClips::new(self, 1))),
|
||||
//Fill::x(Fixed::y(1, ArrangerVOuts::from(self)))))))))))));
|
||||
//Bsp::s(
|
||||
//Bsp::s(
|
||||
//Bsp::s(
|
||||
//Fill::xy(ArrangerVClips::new(self, 1)),
|
||||
//Fill::x(ArrangerVOuts::from(self)))))
|
||||
|
||||
impl Arranger {
|
||||
fn toolbar_view (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
Fill::x(Fixed::y(2, Align::x(TransportView::new(true, &self.clock))))
|
||||
}
|
||||
fn status_view (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
ArrangerStatus::from(self)
|
||||
//let edit_clip = MidiEditClip(&self.editor);
|
||||
////let selectors = When(false, Bsp::e(ClipSelected::play_phrase(&self.player), ClipSelected::next_phrase(&self.player)));
|
||||
//row!([>selectors,<] edit_clip, MidiEditStatus(&self.editor))
|
||||
}
|
||||
fn selector_view (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
row!(
|
||||
//ClipSelected::play_phrase(&self.player),
|
||||
//ClipSelected::next_phrase(&self.player),
|
||||
MidiEditClip(&self.editor),
|
||||
MidiEditStatus(&self.editor),
|
||||
)
|
||||
}
|
||||
fn pool_view (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
let w = self.size.w();
|
||||
let phrase_w = if w > 60 { 20 } else if w > 40 { 15 } else { 10 };
|
||||
let pool_w = if self.pool.visible { phrase_w } else { 0 };
|
||||
let pool = Pull::y(1, Fill::y(Align::e(PoolView(self.pool.visible, &self.pool))));
|
||||
Fixed::x(pool_w, Align::e(Fill::y(PoolView(self.compact, &self.pool))))
|
||||
}
|
||||
fn header (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
let scenes_w = SCENES_W_OFFSET + ArrangerScene::longest_name(&self.scenes) as u16;
|
||||
fn row <T: Content<TuiOut>> (color: ItemPalette, field: T) -> impl Content<TuiOut> {
|
||||
row!(Tui::fg(color.light.rgb, "▎"), Tui::fg(color.lightest.rgb, field))
|
||||
}
|
||||
Push::x(scenes_w, Map(||ArrangerTrack::with_widths(self.tracks.as_slice()), |(_, track, x1, x2), i| {
|
||||
let (w, h) = (ArrangerTrack::MIN_WIDTH.max(x2 - x1), HEADER_H);
|
||||
let color = track.color();
|
||||
Push::x(x1 as u16, Tui::bg(color.base.rgb, Min::xy(w as u16, h, Fixed::xy(w as u16, 5, col!(
|
||||
row(color, Self::format_name(track, w)),
|
||||
row(color, Self::format_elapsed(track, self.clock().timebase())),
|
||||
row(color, Self::format_until_next(track, &self.clock().playhead)),
|
||||
)))))
|
||||
}))
|
||||
}
|
||||
/// name and width of track
|
||||
fn format_name (track: &ArrangerTrack, _w: usize) -> impl Content<TuiOut> {
|
||||
let name = track.name().read().unwrap().clone();
|
||||
Tui::bold(true, Tui::fg(track.color.lightest.rgb, name))
|
||||
}
|
||||
/// beats elapsed
|
||||
fn format_elapsed (track: &ArrangerTrack, timebase: &Arc<Timebase>) -> impl Content<TuiOut> {
|
||||
let mut result = String::new();
|
||||
if let Some((_, Some(phrase))) = track.player.play_phrase().as_ref() {
|
||||
let length = phrase.read().unwrap().length;
|
||||
let elapsed = track.player.pulses_since_start().unwrap();
|
||||
let elapsed = timebase.format_beats_1_short(
|
||||
(elapsed as usize % length) as f64
|
||||
);
|
||||
result = format!("+{elapsed:>}")
|
||||
}
|
||||
result
|
||||
}
|
||||
/// beats until switchover
|
||||
fn format_until_next (track: &ArrangerTrack, current: &Arc<Moment>)
|
||||
-> Option<impl Content<TuiOut>>
|
||||
{
|
||||
let timebase = ¤t.timebase;
|
||||
let mut result = String::new();
|
||||
if let Some((t, _)) = track.player.next_phrase().as_ref() {
|
||||
let target = t.pulse.get();
|
||||
let current = current.pulse.get();
|
||||
if target > current {
|
||||
let remaining = target - current;
|
||||
result = format!("-{:>}", timebase.format_beats_0_short(remaining))
|
||||
}
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
fn ins (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
let scenes_w = SCENES_W_OFFSET + ArrangerScene::longest_name(&self.scenes) as u16;
|
||||
fn row <T: Content<TuiOut>> (color: ItemPalette, field: T) -> impl Content<TuiOut> {
|
||||
row!(Tui::fg(color.light.rgb, "▎"), Tui::fg(color.lightest.rgb, field))
|
||||
}
|
||||
Push::x(scenes_w, Map(||ArrangerTrack::with_widths(self.tracks.as_slice()), |(_, track, x1, x2), i| {
|
||||
let (w, h) = (ArrangerTrack::MIN_WIDTH.max(x2 - x1), HEADER_H);
|
||||
let color = track.color();
|
||||
let input = Self::format_input(track);
|
||||
Push::x(x1 as u16, Tui::bg(color.base.rgb, Min::xy(w as u16, h, Fixed::xy(w as u16, 5, row(color, Self::format_input(track).ok())))))
|
||||
}))
|
||||
}
|
||||
fn format_input (track: &ArrangerTrack) -> Usually<impl Content<TuiOut>> {
|
||||
Ok(format!(">{}", track.player.midi_ins().first().map(|port|port.short_name())
|
||||
.transpose()?.unwrap_or("?".into())))
|
||||
}
|
||||
fn outs (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
let scenes_w = SCENES_W_OFFSET + ArrangerScene::longest_name(&self.scenes) as u16;
|
||||
fn row <T: Content<TuiOut>> (color: ItemPalette, field: T) -> impl Content<TuiOut> {
|
||||
row!(Tui::fg(color.light.rgb, "▎"), Tui::fg(color.lightest.rgb, field))
|
||||
}
|
||||
Push::x(scenes_w, Map(||ArrangerTrack::with_widths(self.tracks.as_slice()), |(_, track, x1, x2), i| {
|
||||
let (w, h) = (ArrangerTrack::MIN_WIDTH.max(x2 - x1), HEADER_H);
|
||||
let color = track.color();
|
||||
Push::x(x2 as u16, Tui::bg(color.base.rgb, Min::xy(w as u16, h, Fixed::xy(w as u16, 5, row(color, Self::format_output(track).ok())))))
|
||||
}))
|
||||
}
|
||||
/// output port
|
||||
fn format_output (track: &ArrangerTrack) -> Usually<impl Content<TuiOut>> {
|
||||
Ok(format!("<{}", track.player.midi_outs().first().map(|port|port.short_name())
|
||||
.transpose()?.unwrap_or("?".into())))
|
||||
}
|
||||
}
|
||||
//render!(TuiOut: (self: Arranger) => {
|
||||
//let pool_w = if self.pool.visible { self.splits[1] } else { 0 };
|
||||
//let color = self.color;
|
||||
|
|
|
|||
|
|
@ -44,20 +44,6 @@ impl ArrangerScene {
|
|||
pub fn color (&self) -> ItemPalette {
|
||||
self.color
|
||||
}
|
||||
pub fn ppqs (scenes: &[Self], factor: usize) -> Vec<(usize, usize)> {
|
||||
let mut total = 0;
|
||||
if factor == 0 {
|
||||
scenes.iter().map(|scene|{
|
||||
let pulses = scene.pulses().max(PPQ);
|
||||
total += pulses;
|
||||
(pulses, total - pulses)
|
||||
}).collect()
|
||||
} else {
|
||||
(0..=scenes.len()).map(|i|{
|
||||
(factor*PPQ, factor*PPQ*i)
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
pub fn longest_name (scenes: &[Self]) -> usize {
|
||||
scenes.iter().map(|s|s.name().read().unwrap().len()).fold(0, usize::max)
|
||||
}
|
||||
|
|
|
|||
354
src/arranger/arranger_tui.rs
Normal file
354
src/arranger/arranger_tui.rs
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
use crate::*;
|
||||
//impl Arranger {
|
||||
//fn render_mode (state: &Self) -> impl Content<TuiOut> + use<'_> {
|
||||
//match state.mode {
|
||||
//ArrangerMode::H => todo!("horizontal arranger"),
|
||||
//ArrangerMode::V(factor) => Self::render_mode_v(state, factor),
|
||||
//}
|
||||
//}
|
||||
//}
|
||||
//render!(TuiOut: (self: Arranger) => {
|
||||
//let pool_w = if self.pool.visible { self.splits[1] } else { 0 };
|
||||
//let color = self.color;
|
||||
//let layout = Bsp::a(Fill::xy(ArrangerStatus::from(self)),
|
||||
//Bsp::n(Fixed::x(pool_w, PoolView(self.pool.visible, &self.pool)),
|
||||
//Bsp::n(TransportView::new(true, &self.clock),
|
||||
//Bsp::s(Fixed::y(1, MidiEditStatus(&self.editor)),
|
||||
//Bsp::n(Fill::x(Fixed::y(20,
|
||||
//Bsp::a(Fill::xy(Tui::bg(color.darkest.rgb, "background")),
|
||||
//Bsp::a(
|
||||
//Fill::xy(Outer(Style::default().fg(color.dark.rgb).bg(color.darkest.rgb))),
|
||||
//Self::render_mode(self))))), Fill::y(&"fixme: self.editor"))))));
|
||||
//self.size.of(layout)
|
||||
//});
|
||||
render!(TuiOut: (self: Arranger) => {
|
||||
let scenes = &self.scenes;
|
||||
let ppqs = Arranger::ppqs(scenes, 1);
|
||||
Fill::xy(self.size.of(
|
||||
Bsp::s(self.toolbar_view(),
|
||||
Bsp::n(self.status_view(),
|
||||
Bsp::n(self.selector_view(),
|
||||
Bsp::w(self.pool_view(),
|
||||
Bsp::s(Align::n(Fill::x(Fixed::y(3, self.header()))),
|
||||
Bsp::s(Align::n(Fill::x(Fixed::y(1, self.ins()))),
|
||||
Bsp::s(Align::n(Fill::x(Fixed::y(1, self.outs()))),
|
||||
Fill::xy(
|
||||
Bsp::a(Fill::xy(Fill::xy(Map(
|
||||
move||scenes.iter(),//.zip(ppqs.iter().map(|row|row.0)),
|
||||
move|scene, i|Arranger::format_scene(&self.tracks, scene, ppqs[i].0)))),
|
||||
Bsp::a(
|
||||
Fill::xy(ArrangerVRowSep::from((self, 1))),
|
||||
Fill::xy(ArrangerVColSep::from(self))))))))))))))
|
||||
});
|
||||
//Align::n(Fill::xy(lay!(
|
||||
//Align::n(Fill::xy(Tui::bg(self.color.darkest.rgb, " "))),
|
||||
//Align::n(Fill::xy(ArrangerVRowSep::from((self, 1)))),
|
||||
//Align::n(Fill::xy(ArrangerVColSep::from(self))),
|
||||
//Align::n(Fill::xy(ArrangerVClips::new(self, 1))),
|
||||
//Align::n(Fill::xy(ArrangerVCursor::from((self, 1))))))))))))))));
|
||||
//Align::n(Fill::xy(":")))))))))))));
|
||||
//"todo:"))))))));
|
||||
//Bsp::s(
|
||||
//Align::n(Fixed::y(1, Fill::x(ArrangerVIns::from(self)))),
|
||||
//Bsp::s(
|
||||
//Fixed::y(20, Align::n(ArrangerVClips::new(self, 1))),
|
||||
//Fill::x(Fixed::y(1, ArrangerVOuts::from(self)))))))))))));
|
||||
//Bsp::s(
|
||||
//Bsp::s(
|
||||
//Bsp::s(
|
||||
//Fill::xy(ArrangerVClips::new(self, 1)),
|
||||
//Fill::x(ArrangerVOuts::from(self)))))
|
||||
|
||||
pub struct ArrangerVClips<'a> {
|
||||
size: &'a Measure<TuiOut>,
|
||||
scenes: &'a Vec<ArrangerScene>,
|
||||
tracks: &'a Vec<ArrangerTrack>,
|
||||
rows: Vec<(usize, usize)>,
|
||||
}
|
||||
impl<'a> ArrangerVClips<'a> {
|
||||
pub fn new (state: &'a Arranger, zoom: usize) -> Self {
|
||||
Self {
|
||||
size: &state.size,
|
||||
tracks: &state.tracks,
|
||||
scenes: &state.scenes,
|
||||
rows: Arranger::ppqs(&state.scenes, zoom),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn row <T: Content<TuiOut>> (color: ItemPalette, field: T) -> impl Content<TuiOut> {
|
||||
Bsp::e(Tui::fg(color.light.rgb, "▎"), Tui::fg(color.lightest.rgb, field))
|
||||
}
|
||||
impl Arranger {
|
||||
fn header (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
let scenes_w = SCENES_W_OFFSET + ArrangerScene::longest_name(&self.scenes) as u16;
|
||||
Push::x(scenes_w, Map(||ArrangerTrack::with_widths(self.tracks.as_slice()), |(_, track, x1, x2), i| {
|
||||
let (w, h) = (ArrangerTrack::MIN_WIDTH.max(x2 - x1), HEADER_H);
|
||||
let color = track.color();
|
||||
let title = format!("{} {} {}", track.name.read().unwrap(), x1, x2);
|
||||
Push::x(x1 as u16, Tui::bg(Color::Rgb(50,0,0), title)) }))
|
||||
//Bsp::s(row(color, Self::format_name(track, w)),
|
||||
//Bsp::s(row(color, Self::format_elapsed(track, self.clock().timebase())),
|
||||
//Bsp::s(row(color, Self::format_until_next(track, &self.clock().playhead)), "test1")))))})
|
||||
}
|
||||
fn ins (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
let scenes_w = SCENES_W_OFFSET + ArrangerScene::longest_name(&self.scenes) as u16;
|
||||
Push::x(scenes_w, Map(||ArrangerTrack::with_widths(self.tracks.as_slice()), |(_, track, x1, x2), i| {
|
||||
let (w, h) = (ArrangerTrack::MIN_WIDTH.max(x2 - x1), HEADER_H);
|
||||
let color = track.color();
|
||||
let input = Self::format_input(track);
|
||||
Fill::xy(Align::n(Push::x(x1 as u16, Tui::bg(color.base.rgb, Min::xy(w as u16, h,
|
||||
Fixed::xy(w as u16, 5, row(color, Self::format_input(track).ok())))))))
|
||||
}))
|
||||
}
|
||||
fn outs (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
let scenes_w = SCENES_W_OFFSET + ArrangerScene::longest_name(&self.scenes) as u16;
|
||||
Push::x(scenes_w, Map(||ArrangerTrack::with_widths(self.tracks.as_slice()), |(_, track, x1, x2), i| {
|
||||
let (w, h) = (ArrangerTrack::MIN_WIDTH.max(x2 - x1), HEADER_H);
|
||||
let color = track.color();
|
||||
Fill::xy(Align::n(Push::x(x2 as u16, Tui::bg(color.base.rgb, Min::xy(w as u16, h,
|
||||
Fixed::xy(w as u16, 5, row(color, Self::format_output(track).ok())))))))
|
||||
}))
|
||||
}
|
||||
pub fn ppqs (scenes: &[ArrangerScene], factor: usize) -> Vec<(usize, usize)> {
|
||||
let mut total = 0;
|
||||
if factor == 0 {
|
||||
scenes.iter().map(|scene|{
|
||||
let pulses = scene.pulses().max(PPQ);
|
||||
total += pulses;
|
||||
(pulses, total - pulses)
|
||||
}).collect()
|
||||
} else {
|
||||
(0..=scenes.len()).map(|i|{
|
||||
(factor*PPQ, factor*PPQ*i)
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_scene <'a> (
|
||||
tracks: &'a [ArrangerTrack], scene: &'a ArrangerScene, pulses: usize
|
||||
) -> impl Content<TuiOut> + use<'a> {
|
||||
let height = 1.max((pulses / PPQ) as u16);
|
||||
let playing = scene.is_playing(tracks);
|
||||
let icon = Tui::bg(
|
||||
scene.color.base.rgb, if playing { "▶ " } else { " " }
|
||||
);
|
||||
let name = Tui::fg_bg(scene.color.lightest.rgb, scene.color.base.rgb,
|
||||
Expand::x(1, Tui::bold(true, scene.name.read().unwrap().clone()))
|
||||
);
|
||||
let clips = Map(||ArrangerTrack::with_widths(tracks), move|(index, track, x1, x2), _|
|
||||
Push::x((x2 - x1) as u16, Self::format_clip(scene, index, track, (x2 - x1) as u16, height))
|
||||
);
|
||||
Fixed::y(height, Bsp::e(icon, Bsp::e(name, clips)))
|
||||
}
|
||||
|
||||
fn format_clip <'a> (
|
||||
scene: &'a ArrangerScene, index: usize, track: &'a ArrangerTrack, w: u16, h: u16
|
||||
) -> impl Content<TuiOut> + use<'a> {
|
||||
scene.clips.get(index).map(|clip|clip.as_ref().map(|phrase|{
|
||||
let phrase = phrase.read().unwrap();
|
||||
let mut bg = TuiTheme::border_bg();
|
||||
let name = phrase.name.to_string();
|
||||
let max_w = name.len().min((w as usize).saturating_sub(2));
|
||||
let color = phrase.color;
|
||||
bg = color.dark.rgb;
|
||||
if let Some((_, Some(ref playing))) = track.player.play_phrase() {
|
||||
if *playing.read().unwrap() == *phrase {
|
||||
bg = color.light.rgb
|
||||
}
|
||||
};
|
||||
Fixed::xy(w, h, &Tui::bg(bg, Push::x(1, Fixed::x(w, &name.as_str()[0..max_w]))));
|
||||
}))
|
||||
}
|
||||
fn toolbar_view (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
Fill::x(Fixed::y(2, Align::x(TransportView::new(true, &self.clock))))
|
||||
}
|
||||
fn status_view (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
ArrangerStatus::from(self)
|
||||
//let edit_clip = MidiEditClip(&self.editor);
|
||||
////let selectors = When(false, Bsp::e(ClipSelected::play_phrase(&self.player), ClipSelected::next_phrase(&self.player)));
|
||||
//row!([>selectors,<] edit_clip, MidiEditStatus(&self.editor))
|
||||
}
|
||||
fn selector_view (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
Bsp::e(
|
||||
//ClipSelected::play_phrase(&self.player),
|
||||
//ClipSelected::next_phrase(&self.player),
|
||||
MidiEditClip(&self.editor),
|
||||
MidiEditStatus(&self.editor),
|
||||
)
|
||||
}
|
||||
fn pool_view (&self) -> impl Content<TuiOut> + use<'_> {
|
||||
let w = self.size.w();
|
||||
let phrase_w = if w > 60 { 20 } else if w > 40 { 15 } else { 10 };
|
||||
let pool_w = if self.pool.visible { phrase_w } else { 0 };
|
||||
let pool = Pull::y(1, Fill::y(Align::e(PoolView(self.pool.visible, &self.pool))));
|
||||
Fixed::x(pool_w, Align::e(Fill::y(PoolView(self.compact, &self.pool))))
|
||||
}
|
||||
/// name and width of track
|
||||
fn format_name (track: &ArrangerTrack, _w: usize) -> impl Content<TuiOut> {
|
||||
let name = track.name().read().unwrap().clone();
|
||||
Tui::bold(true, Tui::fg(track.color.lightest.rgb, name))
|
||||
}
|
||||
/// beats elapsed
|
||||
fn format_elapsed (track: &ArrangerTrack, timebase: &Arc<Timebase>) -> impl Content<TuiOut> {
|
||||
let mut result = String::new();
|
||||
if let Some((_, Some(phrase))) = track.player.play_phrase().as_ref() {
|
||||
let length = phrase.read().unwrap().length;
|
||||
let elapsed = track.player.pulses_since_start().unwrap();
|
||||
let elapsed = timebase.format_beats_1_short(
|
||||
(elapsed as usize % length) as f64
|
||||
);
|
||||
result = format!("+{elapsed:>}")
|
||||
}
|
||||
result
|
||||
}
|
||||
/// beats until switchover
|
||||
fn format_until_next (track: &ArrangerTrack, current: &Arc<Moment>)
|
||||
-> Option<impl Content<TuiOut>>
|
||||
{
|
||||
let timebase = ¤t.timebase;
|
||||
let mut result = String::new();
|
||||
if let Some((t, _)) = track.player.next_phrase().as_ref() {
|
||||
let target = t.pulse.get();
|
||||
let current = current.pulse.get();
|
||||
if target > current {
|
||||
let remaining = target - current;
|
||||
result = format!("-{:>}", timebase.format_beats_0_short(remaining))
|
||||
}
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
fn format_input (track: &ArrangerTrack) -> Usually<impl Content<TuiOut>> {
|
||||
Ok(format!(">{}", track.player.midi_ins().first().map(|port|port.short_name())
|
||||
.transpose()?.unwrap_or("?".into())))
|
||||
}
|
||||
/// output port
|
||||
fn format_output (track: &ArrangerTrack) -> Usually<impl Content<TuiOut>> {
|
||||
Ok(format!("<{}", track.player.midi_outs().first().map(|port|port.short_name())
|
||||
.transpose()?.unwrap_or("?".into())))
|
||||
}
|
||||
}
|
||||
pub struct ArrangerVColSep {
|
||||
fg: Color,
|
||||
cols: Vec<(usize, usize)>,
|
||||
scenes_w: u16
|
||||
}
|
||||
from!(|state:&Arranger|ArrangerVColSep = Self {
|
||||
fg: TuiTheme::separator_fg(false),
|
||||
cols: ArrangerTrack::widths(&state.tracks),
|
||||
scenes_w: SCENES_W_OFFSET + ArrangerScene::longest_name(&state.scenes) as u16,
|
||||
});
|
||||
render!(TuiOut: |self: ArrangerVColSep, to| {
|
||||
let style = Some(Style::default().fg(self.fg));
|
||||
for x in self.cols.iter().map(|col|col.1) {
|
||||
let x = self.scenes_w + to.area().x() + x as u16;
|
||||
for y in to.area().y()..to.area().y2() {
|
||||
to.blit(&"▎", x, y, style);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pub struct ArrangerVRowSep {
|
||||
fg: Color,
|
||||
rows: Vec<(usize, usize)>,
|
||||
}
|
||||
from!(|args:(&Arranger, usize)|ArrangerVRowSep = Self {
|
||||
fg: Color::Rgb(255,255,255,),
|
||||
rows: Arranger::ppqs(&args.0.scenes, args.1),
|
||||
});
|
||||
render!(TuiOut: |self: ArrangerVRowSep, to|for y in self.rows.iter().map(|row|row.1) {
|
||||
let y = to.area().y() + (y / PPQ) as u16 + 1;
|
||||
if y >= to.buffer.area.height { break }
|
||||
for x in to.area().x()..to.area().x2().saturating_sub(2) {
|
||||
//if x < to.buffer.area.x && y < to.buffer.area.y {
|
||||
if let Some(cell) = to.buffer.cell_mut(ratatui::prelude::Position::from((x, y))) {
|
||||
cell.modifier = Modifier::UNDERLINED;
|
||||
cell.underline_color = self.fg;
|
||||
}
|
||||
//}
|
||||
}
|
||||
});
|
||||
|
||||
pub struct ArrangerVCursor {
|
||||
cols: Vec<(usize, usize)>,
|
||||
rows: Vec<(usize, usize)>,
|
||||
color: ItemPalette,
|
||||
reticle: Reticle,
|
||||
selected: ArrangerSelection,
|
||||
scenes_w: u16,
|
||||
}
|
||||
|
||||
from!(|args:(&Arranger, usize)|ArrangerVCursor = Self {
|
||||
cols: ArrangerTrack::widths(&args.0.tracks),
|
||||
rows: Arranger::ppqs(&args.0.scenes, args.1),
|
||||
selected: args.0.selected(),
|
||||
scenes_w: SCENES_W_OFFSET + ArrangerScene::longest_name(&args.0.scenes) as u16,
|
||||
color: args.0.color,
|
||||
reticle: Reticle(Style {
|
||||
fg: Some(args.0.color.lighter.rgb),
|
||||
bg: None,
|
||||
underline_color: None,
|
||||
add_modifier: Modifier::empty(),
|
||||
sub_modifier: Modifier::DIM
|
||||
}),
|
||||
});
|
||||
impl Content<TuiOut> for ArrangerVCursor {
|
||||
fn render (&self, to: &mut TuiOut) {
|
||||
let area = to.area();
|
||||
let focused = true;
|
||||
let selected = self.selected;
|
||||
let get_track_area = |t: usize| [
|
||||
self.scenes_w + area.x() + self.cols[t].1 as u16, area.y(),
|
||||
self.cols[t].0 as u16, area.h(),
|
||||
];
|
||||
let get_scene_area = |s: usize| [
|
||||
area.x(), HEADER_H + area.y() + (self.rows[s].1 / PPQ) as u16,
|
||||
area.w(), (self.rows[s].0 / PPQ) as u16
|
||||
];
|
||||
let get_clip_area = |t: usize, s: usize| [
|
||||
(self.scenes_w + area.x() + self.cols[t].1 as u16).saturating_sub(1),
|
||||
HEADER_H + area.y() + (self.rows[s].1/PPQ) as u16,
|
||||
self.cols[t].0 as u16 + 2,
|
||||
(self.rows[s].0 / PPQ) as u16
|
||||
];
|
||||
let mut track_area: Option<[u16;4]> = None;
|
||||
let mut scene_area: Option<[u16;4]> = None;
|
||||
let mut clip_area: Option<[u16;4]> = None;
|
||||
let area = match selected {
|
||||
ArrangerSelection::Mix => area,
|
||||
ArrangerSelection::Track(t) => {
|
||||
track_area = Some(get_track_area(t));
|
||||
area
|
||||
},
|
||||
ArrangerSelection::Scene(s) => {
|
||||
scene_area = Some(get_scene_area(s));
|
||||
area
|
||||
},
|
||||
ArrangerSelection::Clip(t, s) => {
|
||||
track_area = Some(get_track_area(t));
|
||||
scene_area = Some(get_scene_area(s));
|
||||
clip_area = Some(get_clip_area(t, s));
|
||||
area
|
||||
},
|
||||
};
|
||||
let bg = self.color.lighter.rgb;//Color::Rgb(0, 255, 0);
|
||||
if let Some([x, y, width, height]) = track_area {
|
||||
to.fill_fg([x, y, 1, height], bg);
|
||||
to.fill_fg([x + width, y, 1, height], bg);
|
||||
}
|
||||
if let Some([_, y, _, height]) = scene_area {
|
||||
to.fill_ul([area.x(), y - 1, area.w(), 1], bg);
|
||||
to.fill_ul([area.x(), y + height - 1, area.w(), 1], bg);
|
||||
}
|
||||
if focused {
|
||||
to.place(if let Some(clip_area) = clip_area {
|
||||
clip_area
|
||||
} else if let Some(track_area) = track_area {
|
||||
track_area.clip_h(HEADER_H)
|
||||
} else if let Some(scene_area) = scene_area {
|
||||
scene_area.clip_w(self.scenes_w)
|
||||
} else {
|
||||
area.clip_w(self.scenes_w).clip_h(HEADER_H)
|
||||
}, &self.reticle)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
use crate::*;
|
||||
// egyptian snakes den
|
||||
impl Arranger {
|
||||
pub fn render_mode_v (state: &Arranger, factor: usize) -> impl Content<TuiOut> + use<'_> {
|
||||
lay!(
|
||||
ArrangerVColSep::from(state),
|
||||
ArrangerVRowSep::from((state, factor)),
|
||||
col!(
|
||||
//ArrangerVHead::from(state),
|
||||
ArrangerVIns::from(state),
|
||||
ArrangerVClips::new(state, factor),
|
||||
ArrangerVOuts::from(state),
|
||||
),
|
||||
ArrangerVCursor::from((state, factor)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
use crate::*;
|
||||
use super::*;
|
||||
|
||||
pub struct ArrangerVClips<'a> {
|
||||
size: &'a Measure<TuiOut>,
|
||||
scenes: &'a Vec<ArrangerScene>,
|
||||
tracks: &'a Vec<ArrangerTrack>,
|
||||
rows: Vec<(usize, usize)>,
|
||||
}
|
||||
impl<'a> ArrangerVClips<'a> {
|
||||
pub fn new (state: &'a Arranger, zoom: usize) -> Self {
|
||||
Self {
|
||||
size: &state.size,
|
||||
tracks: &state.tracks,
|
||||
scenes: &state.scenes,
|
||||
rows: ArrangerScene::ppqs(&state.scenes, zoom),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a> Content<TuiOut> for ArrangerVClips<'a> {
|
||||
fn content (&self) -> impl Render<TuiOut> {
|
||||
let iter = ||self.scenes.iter().zip(self.rows.iter().map(|row|row.0));
|
||||
let col = Map(iter, |(scene, pulses), i|Self::format_scene(self.tracks, scene, pulses));
|
||||
Fill::xy(col)
|
||||
}
|
||||
}
|
||||
impl<'a> ArrangerVClips<'a> {
|
||||
|
||||
fn format_scene (
|
||||
tracks: &'a [ArrangerTrack], scene: &'a ArrangerScene, pulses: usize
|
||||
) -> impl Content<TuiOut> + use<'a> {
|
||||
let height = 1.max((pulses / PPQ) as u16);
|
||||
let playing = scene.is_playing(tracks);
|
||||
let icon = Tui::bg(
|
||||
scene.color.base.rgb, if playing { "▶ " } else { " " }
|
||||
);
|
||||
let name = Tui::fg_bg(scene.color.lightest.rgb, scene.color.base.rgb,
|
||||
Expand::x(1, Tui::bold(true, scene.name.read().unwrap().clone()))
|
||||
);
|
||||
let clips = Map(||ArrangerTrack::with_widths(tracks), move|(index, track, x1, x2), _|
|
||||
Push::x((x2 - x1) as u16, Self::format_clip(scene, index, track, (x2 - x1) as u16, height))
|
||||
);
|
||||
Fixed::y(height, row!(icon, name, clips))
|
||||
}
|
||||
|
||||
fn format_clip (
|
||||
scene: &'a ArrangerScene, index: usize, track: &'a ArrangerTrack, w: u16, h: u16
|
||||
) -> impl Content<TuiOut> + use<'a> {
|
||||
scene.clips.get(index).map(|clip|clip.as_ref().map(|phrase|{
|
||||
let phrase = phrase.read().unwrap();
|
||||
let mut bg = TuiTheme::border_bg();
|
||||
let name = phrase.name.to_string();
|
||||
let max_w = name.len().min((w as usize).saturating_sub(2));
|
||||
let color = phrase.color;
|
||||
bg = color.dark.rgb;
|
||||
if let Some((_, Some(ref playing))) = track.player.play_phrase() {
|
||||
if *playing.read().unwrap() == *phrase {
|
||||
bg = color.light.rgb
|
||||
}
|
||||
};
|
||||
Fixed::xy(w, h, &Tui::bg(bg, Push::x(1, Fixed::x(w, &name.as_str()[0..max_w]))));
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
use crate::*;
|
||||
use super::*;
|
||||
|
||||
pub struct ArrangerVCursor {
|
||||
cols: Vec<(usize, usize)>,
|
||||
rows: Vec<(usize, usize)>,
|
||||
color: ItemPalette,
|
||||
reticle: Reticle,
|
||||
selected: ArrangerSelection,
|
||||
scenes_w: u16,
|
||||
}
|
||||
|
||||
from!(|args:(&Arranger, usize)|ArrangerVCursor = Self {
|
||||
cols: ArrangerTrack::widths(&args.0.tracks),
|
||||
rows: ArrangerScene::ppqs(&args.0.scenes, args.1),
|
||||
selected: args.0.selected(),
|
||||
scenes_w: SCENES_W_OFFSET + ArrangerScene::longest_name(&args.0.scenes) as u16,
|
||||
color: args.0.color,
|
||||
reticle: Reticle(Style {
|
||||
fg: Some(args.0.color.lighter.rgb),
|
||||
bg: None,
|
||||
underline_color: None,
|
||||
add_modifier: Modifier::empty(),
|
||||
sub_modifier: Modifier::DIM
|
||||
}),
|
||||
});
|
||||
impl Content<TuiOut> for ArrangerVCursor {
|
||||
fn render (&self, to: &mut TuiOut) {
|
||||
let area = to.area();
|
||||
let focused = true;
|
||||
let selected = self.selected;
|
||||
let get_track_area = |t: usize| [
|
||||
self.scenes_w + area.x() + self.cols[t].1 as u16, area.y(),
|
||||
self.cols[t].0 as u16, area.h(),
|
||||
];
|
||||
let get_scene_area = |s: usize| [
|
||||
area.x(), HEADER_H + area.y() + (self.rows[s].1 / PPQ) as u16,
|
||||
area.w(), (self.rows[s].0 / PPQ) as u16
|
||||
];
|
||||
let get_clip_area = |t: usize, s: usize| [
|
||||
(self.scenes_w + area.x() + self.cols[t].1 as u16).saturating_sub(1),
|
||||
HEADER_H + area.y() + (self.rows[s].1/PPQ) as u16,
|
||||
self.cols[t].0 as u16 + 2,
|
||||
(self.rows[s].0 / PPQ) as u16
|
||||
];
|
||||
let mut track_area: Option<[u16;4]> = None;
|
||||
let mut scene_area: Option<[u16;4]> = None;
|
||||
let mut clip_area: Option<[u16;4]> = None;
|
||||
let area = match selected {
|
||||
ArrangerSelection::Mix => area,
|
||||
ArrangerSelection::Track(t) => {
|
||||
track_area = Some(get_track_area(t));
|
||||
area
|
||||
},
|
||||
ArrangerSelection::Scene(s) => {
|
||||
scene_area = Some(get_scene_area(s));
|
||||
area
|
||||
},
|
||||
ArrangerSelection::Clip(t, s) => {
|
||||
track_area = Some(get_track_area(t));
|
||||
scene_area = Some(get_scene_area(s));
|
||||
clip_area = Some(get_clip_area(t, s));
|
||||
area
|
||||
},
|
||||
};
|
||||
let bg = self.color.lighter.rgb;//Color::Rgb(0, 255, 0);
|
||||
if let Some([x, y, width, height]) = track_area {
|
||||
to.fill_fg([x, y, 1, height], bg);
|
||||
to.fill_fg([x + width, y, 1, height], bg);
|
||||
}
|
||||
if let Some([_, y, _, height]) = scene_area {
|
||||
to.fill_ul([area.x(), y - 1, area.w(), 1], bg);
|
||||
to.fill_ul([area.x(), y + height - 1, area.w(), 1], bg);
|
||||
}
|
||||
if focused {
|
||||
to.place(if let Some(clip_area) = clip_area {
|
||||
clip_area
|
||||
} else if let Some(track_area) = track_area {
|
||||
track_area.clip_h(HEADER_H)
|
||||
} else if let Some(scene_area) = scene_area {
|
||||
scene_area.clip_w(self.scenes_w)
|
||||
} else {
|
||||
area.clip_w(self.scenes_w).clip_h(HEADER_H)
|
||||
}, &self.reticle)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
use crate::*;
|
||||
use super::*;
|
||||
|
||||
pub struct ArrangerVIns<'a> {
|
||||
size: &'a Measure<TuiOut>,
|
||||
tracks: &'a Vec<ArrangerTrack>,
|
||||
scenes_w: u16,
|
||||
}
|
||||
impl ArrangerVIns<'_> {
|
||||
/// input port
|
||||
fn format_input (track: &ArrangerTrack) -> Usually<impl Content<TuiOut>> {
|
||||
Ok(format!(">{}", track.player.midi_ins().first().map(|port|port.short_name())
|
||||
.transpose()?.unwrap_or("?".into())))
|
||||
}
|
||||
}
|
||||
|
||||
from!(<'a>|args: &'a Arranger|ArrangerVIns<'a> = Self {
|
||||
size: &args.size,
|
||||
tracks: &args.tracks,
|
||||
scenes_w: SCENES_W_OFFSET + ArrangerScene::longest_name(&args.scenes) as u16,
|
||||
});
|
||||
|
||||
render!(TuiOut: (self: ArrangerVIns<'a>) => {
|
||||
});
|
||||
|
||||
pub struct ArrangerVOuts<'a> {
|
||||
size: &'a Measure<TuiOut>,
|
||||
tracks: &'a Vec<ArrangerTrack>,
|
||||
scenes_w: u16,
|
||||
}
|
||||
impl ArrangerVOuts<'_> {
|
||||
}
|
||||
|
||||
from!(<'a>|args: &'a Arranger|ArrangerVOuts<'a> = Self {
|
||||
size: &args.size,
|
||||
tracks: &args.tracks,
|
||||
scenes_w: SCENES_W_OFFSET + ArrangerScene::longest_name(&args.scenes) as u16,
|
||||
});
|
||||
|
||||
render!(TuiOut: (self: ArrangerVOuts<'a>) => {
|
||||
});
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
use crate::*;
|
||||
use super::*;
|
||||
|
||||
pub struct ArrangerVColSep {
|
||||
fg: Color,
|
||||
cols: Vec<(usize, usize)>,
|
||||
scenes_w: u16
|
||||
}
|
||||
from!(|state:&Arranger|ArrangerVColSep = Self {
|
||||
fg: TuiTheme::separator_fg(false),
|
||||
cols: ArrangerTrack::widths(&state.tracks),
|
||||
scenes_w: SCENES_W_OFFSET + ArrangerScene::longest_name(&state.scenes) as u16,
|
||||
});
|
||||
render!(TuiOut: |self: ArrangerVColSep, to| {
|
||||
let style = Some(Style::default().fg(self.fg));
|
||||
for x in self.cols.iter().map(|col|col.1) {
|
||||
let x = self.scenes_w + to.area().x() + x as u16;
|
||||
for y in to.area().y()..to.area().y2() {
|
||||
to.blit(&"▎", x, y, style);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pub struct ArrangerVRowSep {
|
||||
fg: Color,
|
||||
rows: Vec<(usize, usize)>,
|
||||
}
|
||||
from!(|args:(&Arranger, usize)|ArrangerVRowSep = Self {
|
||||
fg: Color::Rgb(255,255,255,),
|
||||
rows: ArrangerScene::ppqs(&args.0.scenes, args.1),
|
||||
});
|
||||
render!(TuiOut: |self: ArrangerVRowSep, to|for y in self.rows.iter().map(|row|row.1) {
|
||||
let y = to.area().y() + (y / PPQ) as u16 + 1;
|
||||
if y >= to.buffer.area.height { break }
|
||||
for x in to.area().x()..to.area().x2().saturating_sub(2) {
|
||||
//if x < to.buffer.area.x && y < to.buffer.area.y {
|
||||
if let Some(cell) = to.buffer.cell_mut(ratatui::prelude::Position::from((x, y))) {
|
||||
cell.modifier = Modifier::UNDERLINED;
|
||||
cell.underline_color = self.fg;
|
||||
}
|
||||
//}
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue