mirror of
https://codeberg.org/unspeaker/tek.git
synced 2025-12-06 19:56:42 +01:00
show sequencer with arranger
This commit is contained in:
parent
964e8382d3
commit
a533951fc6
10 changed files with 185 additions and 137 deletions
|
|
@ -43,8 +43,9 @@ use crossterm::terminal::{
|
||||||
|
|
||||||
submod! {
|
submod! {
|
||||||
exit
|
exit
|
||||||
render
|
|
||||||
handle
|
handle
|
||||||
|
render
|
||||||
|
render_split
|
||||||
time_base
|
time_base
|
||||||
time_note
|
time_note
|
||||||
time_tick
|
time_tick
|
||||||
|
|
|
||||||
|
|
@ -241,101 +241,6 @@ impl<'a> Render for IfElse<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone)]
|
|
||||||
pub enum Direction { Down, Right }
|
|
||||||
|
|
||||||
impl Direction {
|
|
||||||
pub fn split <'a, const N: usize> (&self, items: [&'a (dyn Render + Sync);N]) -> Split<'a, N> {
|
|
||||||
Split(*self, items)
|
|
||||||
}
|
|
||||||
pub fn split_focus <'a> (&self, index: usize, items: Renderables<'a>, style: Style) -> SplitFocus<'a> {
|
|
||||||
SplitFocus(*self, index, items, style)
|
|
||||||
}
|
|
||||||
pub fn is_down (&self) -> bool {
|
|
||||||
match self { Self::Down => true, _ => false }
|
|
||||||
}
|
|
||||||
pub fn is_right (&self) -> bool {
|
|
||||||
match self { Self::Right => true, _ => false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Split<'a, const N: usize>(
|
|
||||||
pub Direction, pub [&'a (dyn Render + Sync);N]
|
|
||||||
);
|
|
||||||
|
|
||||||
impl<'a, const N: usize> Split<'a, N> {
|
|
||||||
pub fn down (items: [&'a (dyn Render + Sync);N]) -> Self {
|
|
||||||
Self(Direction::Down, items)
|
|
||||||
}
|
|
||||||
pub fn right (items: [&'a (dyn Render + Sync);N]) -> Self {
|
|
||||||
Self(Direction::Right, items)
|
|
||||||
}
|
|
||||||
pub fn render_areas (&self, buf: &mut Buffer, area: Rect) -> Usually<(Rect, Vec<Rect>)> {
|
|
||||||
let Rect { mut x, mut y, mut width, mut height } = area;
|
|
||||||
let mut areas = vec![];
|
|
||||||
for item in self.1 {
|
|
||||||
if width == 0 || height == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
let result = item.render(buf, Rect { x, y, width, height })?;
|
|
||||||
match self.0 {
|
|
||||||
Direction::Down => {
|
|
||||||
y = y + result.height;
|
|
||||||
height = height.saturating_sub(result.height);
|
|
||||||
},
|
|
||||||
Direction::Right => {
|
|
||||||
x = x + result.width;
|
|
||||||
width = width.saturating_sub(result.width);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
areas.push(area);
|
|
||||||
}
|
|
||||||
Ok((area, areas))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, const N: usize> Render for Split<'a, N> {
|
|
||||||
fn render (&self, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
|
||||||
Ok(self.render_areas(buf, area)?.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Renderables<'a> = &'a [&'a (dyn Render + Send + Sync)];
|
|
||||||
|
|
||||||
pub struct SplitFocus<'a>(pub Direction, pub usize, pub Renderables<'a>, pub Style);
|
|
||||||
|
|
||||||
impl<'a> SplitFocus<'a> {
|
|
||||||
pub fn render_areas (&self, buf: &mut Buffer, area: Rect) -> Usually<(Rect, Vec<Rect>)> {
|
|
||||||
let Rect { mut x, mut y, mut width, mut height } = area;
|
|
||||||
let mut areas = vec![];
|
|
||||||
for item in self.2.iter() {
|
|
||||||
if width == 0 || height == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
let result = item.render(buf, Rect { x, y, width, height })?;
|
|
||||||
areas.push(result);
|
|
||||||
match self.0 {
|
|
||||||
Direction::Down => {
|
|
||||||
y = y + result.height;
|
|
||||||
height = height.saturating_sub(result.height);
|
|
||||||
},
|
|
||||||
Direction::Right => {
|
|
||||||
x = x + result.width;
|
|
||||||
width = width.saturating_sub(result.width);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Lozenge(self.3).draw(buf, result)?;
|
|
||||||
}
|
|
||||||
Ok((area, areas))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Render for SplitFocus<'a> {
|
|
||||||
fn render (&self, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
|
||||||
Ok(self.render_areas(buf, area)?.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait Theme {
|
pub trait Theme {
|
||||||
const BG0: Color;
|
const BG0: Color;
|
||||||
const BG1: Color;
|
const BG1: Color;
|
||||||
|
|
|
||||||
104
crates/tek_core/src/render_split.rs
Normal file
104
crates/tek_core/src/render_split.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
pub enum Direction {
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Left,
|
||||||
|
Right
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Direction {
|
||||||
|
pub fn split <'a, const N: usize> (&self, items: [&'a (dyn Render + Sync);N]) -> Split<'a, N> {
|
||||||
|
Split(*self, items)
|
||||||
|
}
|
||||||
|
pub fn split_focus <'a> (&self, index: usize, items: Renderables<'a>, style: Style) -> SplitFocus<'a> {
|
||||||
|
SplitFocus(*self, index, items, style)
|
||||||
|
}
|
||||||
|
pub fn is_down (&self) -> bool {
|
||||||
|
match self { Self::Down => true, _ => false }
|
||||||
|
}
|
||||||
|
pub fn is_right (&self) -> bool {
|
||||||
|
match self { Self::Right => true, _ => false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Split<'a, const N: usize>(
|
||||||
|
pub Direction, pub [&'a (dyn Render + Sync);N]
|
||||||
|
);
|
||||||
|
|
||||||
|
impl<'a, const N: usize> Split<'a, N> {
|
||||||
|
pub fn down (items: [&'a (dyn Render + Sync);N]) -> Self {
|
||||||
|
Self(Direction::Down, items)
|
||||||
|
}
|
||||||
|
pub fn right (items: [&'a (dyn Render + Sync);N]) -> Self {
|
||||||
|
Self(Direction::Right, items)
|
||||||
|
}
|
||||||
|
pub fn render_areas (&self, buf: &mut Buffer, area: Rect) -> Usually<(Rect, Vec<Rect>)> {
|
||||||
|
let Rect { mut x, mut y, mut width, mut height } = area;
|
||||||
|
let mut areas = vec![];
|
||||||
|
for item in self.1 {
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
let result = item.render(buf, Rect { x, y, width, height })?;
|
||||||
|
match self.0 {
|
||||||
|
Direction::Down => {
|
||||||
|
y = y + result.height;
|
||||||
|
height = height.saturating_sub(result.height);
|
||||||
|
},
|
||||||
|
Direction::Right => {
|
||||||
|
x = x + result.width;
|
||||||
|
width = width.saturating_sub(result.width);
|
||||||
|
},
|
||||||
|
_ => unimplemented!()
|
||||||
|
};
|
||||||
|
areas.push(area);
|
||||||
|
}
|
||||||
|
Ok((area, areas))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, const N: usize> Render for Split<'a, N> {
|
||||||
|
fn render (&self, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
||||||
|
Ok(self.render_areas(buf, area)?.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Renderables<'a> = &'a [&'a (dyn Render + Send + Sync)];
|
||||||
|
|
||||||
|
pub struct SplitFocus<'a>(pub Direction, pub usize, pub Renderables<'a>, pub Style);
|
||||||
|
|
||||||
|
impl<'a> SplitFocus<'a> {
|
||||||
|
pub fn render_areas (&self, buf: &mut Buffer, area: Rect) -> Usually<(Rect, Vec<Rect>)> {
|
||||||
|
let Rect { mut x, mut y, mut width, mut height } = area;
|
||||||
|
let mut areas = vec![];
|
||||||
|
for item in self.2.iter() {
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
let result = item.render(buf, Rect { x, y, width, height })?;
|
||||||
|
areas.push(result);
|
||||||
|
match self.0 {
|
||||||
|
Direction::Down => {
|
||||||
|
y = y + result.height;
|
||||||
|
height = height.saturating_sub(result.height);
|
||||||
|
},
|
||||||
|
Direction::Right => {
|
||||||
|
x = x + result.width;
|
||||||
|
width = width.saturating_sub(result.width);
|
||||||
|
},
|
||||||
|
_ => unimplemented!()
|
||||||
|
}
|
||||||
|
Lozenge(self.3).draw(buf, result)?;
|
||||||
|
}
|
||||||
|
Ok((area, areas))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Render for SplitFocus<'a> {
|
||||||
|
fn render (&self, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
||||||
|
Ok(self.render_areas(buf, area)?.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@ impl<'a> Render for TrackView<'a> {
|
||||||
match self.direction {
|
match self.direction {
|
||||||
Direction::Down => area.width = area.width.min(40),
|
Direction::Down => area.width = area.width.min(40),
|
||||||
Direction::Right => area.width = area.width.min(10),
|
Direction::Right => area.width = area.width.min(10),
|
||||||
|
_ => { unimplemented!() },
|
||||||
}
|
}
|
||||||
fill_bg(buf, area, Nord::bg_lo(self.focused, self.entered));
|
fill_bg(buf, area, Nord::bg_lo(self.focused, self.entered));
|
||||||
let devices: Vec<&(dyn Render + Send + Sync)> = chain.devices.as_slice()
|
let devices: Vec<&(dyn Render + Send + Sync)> = chain.devices.as_slice()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Clip launcher and arrangement editor.
|
//! Clip launcher and arrangement editor.
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
use tek_core::Direction;
|
||||||
|
|
||||||
/// Represents the tracks and scenes of the composition.
|
/// Represents the tracks and scenes of the composition.
|
||||||
pub struct Arranger {
|
pub struct Arranger {
|
||||||
|
|
@ -16,22 +17,52 @@ pub struct Arranger {
|
||||||
pub scenes: Vec<Scene>,
|
pub scenes: Vec<Scene>,
|
||||||
pub focused: bool,
|
pub focused: bool,
|
||||||
pub entered: bool,
|
pub entered: bool,
|
||||||
pub fixed_height: bool,
|
|
||||||
pub transport: Option<Arc<RwLock<TransportToolbar>>>,
|
pub transport: Option<Arc<RwLock<TransportToolbar>>>,
|
||||||
|
pub show_sequencer: Option<Direction>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render!(Arranger |self, buf, area| {
|
||||||
|
let arrangement = |buf, area| match self.mode {
|
||||||
|
ArrangerViewMode::Horizontal =>
|
||||||
|
super::arranger_view_h::draw(self, buf, area),
|
||||||
|
ArrangerViewMode::VerticalCompact1 =>
|
||||||
|
super::arranger_view_v::draw_compact_1(self, buf, area),
|
||||||
|
ArrangerViewMode::VerticalCompact2 =>
|
||||||
|
super::arranger_view_v::draw_compact_2(self, buf, area),
|
||||||
|
ArrangerViewMode::VerticalExpanded =>
|
||||||
|
super::arranger_view_v::draw_expanded(self, buf, area),
|
||||||
|
};
|
||||||
|
if let Some(direction) = self.show_sequencer {
|
||||||
|
let used = arrangement(buf, area)?;
|
||||||
|
match direction {
|
||||||
|
Direction::Down => {
|
||||||
|
let area = Rect {
|
||||||
|
y: area.y + used.height,
|
||||||
|
height: area.height - used.height,
|
||||||
|
..area
|
||||||
|
};
|
||||||
|
self.sequencer().map(|sequencer|sequencer.render(buf, area));
|
||||||
|
},
|
||||||
|
_ => unimplemented!()
|
||||||
|
}
|
||||||
|
Ok(area)
|
||||||
|
} else {
|
||||||
|
arrangement(buf, area)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
impl Arranger {
|
impl Arranger {
|
||||||
pub fn new (name: &str) -> Self {
|
pub fn new (name: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
mode: ArrangerViewMode::Vertical,
|
mode: ArrangerViewMode::VerticalCompact2,
|
||||||
selected: ArrangerFocus::Clip(0, 0),
|
selected: ArrangerFocus::Clip(0, 0),
|
||||||
scenes: vec![],
|
scenes: vec![],
|
||||||
tracks: vec![],
|
tracks: vec![],
|
||||||
entered: true,
|
entered: true,
|
||||||
focused: true,
|
focused: true,
|
||||||
fixed_height: false,
|
transport: None,
|
||||||
transport: None
|
show_sequencer: Some(Direction::Down),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn activate (&mut self) {
|
pub fn activate (&mut self) {
|
||||||
|
|
@ -60,9 +91,10 @@ impl Arranger {
|
||||||
.flatten()
|
.flatten()
|
||||||
}
|
}
|
||||||
pub fn show_phrase (&mut self) -> Usually<()> {
|
pub fn show_phrase (&mut self) -> Usually<()> {
|
||||||
unimplemented!()
|
//unimplemented!()
|
||||||
//let phrase = self.phrase();
|
//let phrase = self.phrase();
|
||||||
//self.sequencer.show(phrase)
|
//self.sequencer.show(phrase)
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn phrase (&self) -> Option<&Arc<RwLock<Phrase>>> {
|
pub fn phrase (&self) -> Option<&Arc<RwLock<Phrase>>> {
|
||||||
let track_id = self.selected.track()?;
|
let track_id = self.selected.track()?;
|
||||||
|
|
@ -142,12 +174,3 @@ impl Arranger {
|
||||||
format!("Scene {}", self.scenes.len() + 1)
|
format!("Scene {}", self.scenes.len() + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render!(Arranger |self, buf, area| match self.mode {
|
|
||||||
ArrangerViewMode::Horizontal =>
|
|
||||||
super::arranger_view_h::draw(self, buf, area),
|
|
||||||
ArrangerViewMode::Vertical =>
|
|
||||||
super::arranger_view_v::draw_expanded(self, buf, area),
|
|
||||||
ArrangerViewMode::VerticalCompact =>
|
|
||||||
super::arranger_view_v::draw_compact(self, buf, area),
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ pub struct ArrangerCli {
|
||||||
/// Whether to include a transport toolbar (default: true)
|
/// Whether to include a transport toolbar (default: true)
|
||||||
#[arg(short, long)] transport: Option<bool>,
|
#[arg(short, long)] transport: Option<bool>,
|
||||||
/// Number of tracks
|
/// Number of tracks
|
||||||
#[arg(short = 'x', long)] tracks: Option<usize>,
|
#[arg(short = 'x', long, default_value_t = 1)] tracks: usize,
|
||||||
/// Number of scenes
|
/// Number of scenes
|
||||||
#[arg(short, long)] scenes: Option<usize>,
|
#[arg(short, long, default_value_t = 1)] scenes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Arranger {
|
impl Arranger {
|
||||||
|
|
@ -26,17 +26,12 @@ impl Arranger {
|
||||||
if args.transport == Some(true) {
|
if args.transport == Some(true) {
|
||||||
arr.transport = Some(Arc::new(RwLock::new(TransportToolbar::new(None))));
|
arr.transport = Some(Arc::new(RwLock::new(TransportToolbar::new(None))));
|
||||||
}
|
}
|
||||||
if let Some(tracks) = args.tracks {
|
for _ in 0..args.tracks {
|
||||||
for _ in 0..tracks {
|
arr.track_add(None)?;
|
||||||
arr.track_add(None)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(scenes) = args.scenes {
|
for _ in 0..args.scenes {
|
||||||
for _ in 0..scenes {
|
arr.scene_add(None)?;
|
||||||
arr.scene_add(None)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(arr)
|
Ok(arr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,12 @@ pub const KEYMAP_ARRANGER: &'static [KeyBinding<Arranger>] = keymap!(Arranger {
|
||||||
arranger.activate();
|
arranger.activate();
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}],
|
}],
|
||||||
|
[Char('a'), CONTROL, "scene_add", "add a new scene", |arranger: &mut Arranger| {
|
||||||
|
arranger.scene_add(None)?;
|
||||||
|
Ok(true)
|
||||||
|
}],
|
||||||
|
[Char('t'), CONTROL, "track_add", "add a new track", |arranger: &mut Arranger| {
|
||||||
|
arranger.track_add(None)?;
|
||||||
|
Ok(true)
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
/// Display mode of arranger
|
/// Display mode of arranger
|
||||||
pub enum ArrangerViewMode {
|
pub enum ArrangerViewMode {
|
||||||
Vertical,
|
VerticalExpanded,
|
||||||
VerticalCompact,
|
VerticalCompact1,
|
||||||
|
VerticalCompact2,
|
||||||
Horizontal,
|
Horizontal,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ArrangerViewMode {
|
impl ArrangerViewMode {
|
||||||
pub fn to_next (&mut self) {
|
pub fn to_next (&mut self) {
|
||||||
*self = match self {
|
*self = match self {
|
||||||
Self::Vertical => Self::VerticalCompact,
|
Self::VerticalExpanded => Self::VerticalCompact1,
|
||||||
Self::VerticalCompact => Self::Horizontal,
|
Self::VerticalCompact1 => Self::VerticalCompact2,
|
||||||
Self::Horizontal => Self::Vertical,
|
Self::VerticalCompact2 => Self::Horizontal,
|
||||||
|
Self::Horizontal => Self::VerticalExpanded,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
pub fn draw_expanded (state: &Arranger, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
/// Draw arranger with 1 row per scene.
|
||||||
|
pub fn draw_compact_1 (state: &Arranger, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
||||||
let track_cols = track_clip_name_lengths(state.tracks.as_slice());
|
let track_cols = track_clip_name_lengths(state.tracks.as_slice());
|
||||||
let scene_rows = scene_ppqs(state.tracks.as_slice(), state.scenes.as_slice());
|
let scene_rows = (0..=state.scenes.len()).map(|i|(96, 96*i)).collect::<Vec<_>>();
|
||||||
draw(state, buf, area, track_cols.as_slice(), scene_rows.as_slice())
|
draw(state, buf, area, track_cols.as_slice(), scene_rows.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw_compact (state: &Arranger, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
/// Draw arranger with 2 rows per scene.
|
||||||
|
pub fn draw_compact_2 (state: &Arranger, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
||||||
let track_cols = track_clip_name_lengths(state.tracks.as_slice());
|
let track_cols = track_clip_name_lengths(state.tracks.as_slice());
|
||||||
let scene_rows = (0..=state.scenes.len()+3).map(|i|(96, 96*i)).collect::<Vec<_>>();
|
let scene_rows = (0..=state.scenes.len()).map(|i|(192, 192*i)).collect::<Vec<_>>();
|
||||||
|
draw(state, buf, area, track_cols.as_slice(), scene_rows.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw arranger with number of rows per scene corresponding to duration of scene.
|
||||||
|
pub fn draw_expanded (state: &Arranger, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
||||||
|
let track_cols = track_clip_name_lengths(state.tracks.as_slice());
|
||||||
|
let scene_rows = scene_ppqs(state.tracks.as_slice(), state.scenes.as_slice());
|
||||||
draw(state, buf, area, track_cols.as_slice(), scene_rows.as_slice())
|
draw(state, buf, area, track_cols.as_slice(), scene_rows.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ pub fn scene_name_max_len (scenes: &[Scene]) -> usize {
|
||||||
pub fn scene_ppqs (tracks: &[Sequencer], scenes: &[Scene]) -> Vec<(usize, usize)> {
|
pub fn scene_ppqs (tracks: &[Sequencer], scenes: &[Scene]) -> Vec<(usize, usize)> {
|
||||||
let mut total = 0;
|
let mut total = 0;
|
||||||
let mut scenes: Vec<(usize, usize)> = scenes.iter().map(|scene|{
|
let mut scenes: Vec<(usize, usize)> = scenes.iter().map(|scene|{
|
||||||
let pulses = scene.pulses(tracks);
|
let pulses = scene.pulses(tracks).max(96);
|
||||||
total = total + pulses;
|
total = total + pulses;
|
||||||
(pulses, total - pulses)
|
(pulses, total - pulses)
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue