Merge pull request #415 from grovebit/tab-to-transient
Tab to Transient: every selected track, Alt+Tab back, Shift+Tab extends
This commit is contained in:
commit
c2788c50d4
|
|
@ -28,6 +28,7 @@ mod query;
|
||||||
pub(crate) mod sampler;
|
pub(crate) mod sampler;
|
||||||
mod setup_more;
|
mod setup_more;
|
||||||
mod surround;
|
mod surround;
|
||||||
|
mod tab;
|
||||||
mod track;
|
mod track;
|
||||||
mod track_more;
|
mod track_more;
|
||||||
mod transport;
|
mod transport;
|
||||||
|
|
@ -115,6 +116,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||||
v.extend(mix::specs());
|
v.extend(mix::specs());
|
||||||
v.extend(options::specs());
|
v.extend(options::specs());
|
||||||
v.extend(transport::specs());
|
v.extend(transport::specs());
|
||||||
|
v.extend(tab::specs());
|
||||||
v.extend(query::specs());
|
v.extend(query::specs());
|
||||||
v.extend(health::specs());
|
v.extend(health::specs());
|
||||||
v.extend(audiosuite::specs());
|
v.extend(audiosuite::specs());
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
//! Tab and Tab to Transient: the insertion point or selection end to the next or previous clip boundary, transient or note.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{TransportRequest, cmd};
|
||||||
|
use serde_json::json;
|
||||||
|
use soundcraft_model::{Clip, ClipContent, Session, SourceAudio};
|
||||||
|
use soundcraft_time::SampleRate;
|
||||||
|
use std::sync::{Arc, Mutex, PoisonError, Weak};
|
||||||
|
|
||||||
|
/// Transients are found in fixed blocks of each clip, so a long clip costs a block per press and
|
||||||
|
/// Tab and Alt+Tab always see the same transients.
|
||||||
|
const BLOCK_SECS: f64 = 10.0;
|
||||||
|
const SCAN_LIMIT: i64 = 64;
|
||||||
|
const CACHE_LIMIT: usize = 64;
|
||||||
|
|
||||||
|
/// Blocks already analysed, reused while their clips, clip effects and audio stay the same.
|
||||||
|
static ANALYSED: Mutex<Vec<Analysed>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
struct Analysed {
|
||||||
|
track: TrackId,
|
||||||
|
around: Range,
|
||||||
|
rate: SampleRate,
|
||||||
|
clips: Vec<Clip>,
|
||||||
|
audio: Vec<Weak<SourceAudio>>,
|
||||||
|
fx: Vec<(String, f64)>,
|
||||||
|
hits: Vec<Samples>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
|
vec![cmd!(noundo "edit.tab", "Tab", [], None, "{back?: bool, extend?: bool (extend the selection)}", always, tab)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tab(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||||
|
let (back, extend) = (bool_or(p, "back", false), bool_or(p, "extend", false));
|
||||||
|
let s = e.session();
|
||||||
|
let sel = s.edit.selection;
|
||||||
|
let to = |from| stop(s, from, back);
|
||||||
|
let next = match (extend, back) {
|
||||||
|
(false, _) => to(sel.start).map(Range::point),
|
||||||
|
(true, false) => to(sel.end).map(|x| Range::new(sel.start, x)),
|
||||||
|
// Alt+Shift+Tab pulls the end back while a stop lies inside the selection, then grows the start.
|
||||||
|
(true, true) => match to(sel.end) {
|
||||||
|
Some(x) if x > sel.start => Some(Range::new(sel.start, x)),
|
||||||
|
_ => to(sel.start).map(|x| Range::new(x, sel.end)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let Some(r) = next else { return Ok(json!({"selection": sel})) };
|
||||||
|
let s = e.session_mut();
|
||||||
|
s.edit.selection = r;
|
||||||
|
s.edit.selected_clips.clear();
|
||||||
|
if s.edit.link_timeline_edit {
|
||||||
|
s.edit.timeline_selection = r;
|
||||||
|
}
|
||||||
|
if !extend {
|
||||||
|
s.edit.playhead = r.start;
|
||||||
|
e.transport_requests.push(TransportRequest::Locate(r.start));
|
||||||
|
}
|
||||||
|
Ok(json!({"selection": r}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nearest stop after `from` (before it when `back`) on the selected tracks.
|
||||||
|
fn stop(s: &Session, from: Samples, back: bool) -> Option<Samples> {
|
||||||
|
let nearer = |x: Samples, best: Option<Samples>| {
|
||||||
|
if back { x < from && best.is_none_or(|b| x > b) } else { x > from && best.is_none_or(|b| x < b) }
|
||||||
|
};
|
||||||
|
let transients = s.edit.tab_to_transient;
|
||||||
|
let tracks: Vec<_> = s.tracks.iter().filter(|t| s.edit.selected_tracks.contains(&t.id)).collect();
|
||||||
|
let mut stops = Vec::new();
|
||||||
|
for c in tracks.iter().flat_map(|t| t.clips()) {
|
||||||
|
let sync = (c.sync_point > 0 && c.sync_point < c.length).then_some(c.start.saturating_add(c.sync_point));
|
||||||
|
let notes: Vec<Samples> = match &c.content {
|
||||||
|
ClipContent::Midi { sequence } if transients => {
|
||||||
|
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||||
|
let ticks = sequence.notes.iter().flat_map(|n| {
|
||||||
|
let on = base.saturating_add(n.start);
|
||||||
|
[on, on.saturating_add(n.length)]
|
||||||
|
});
|
||||||
|
ticks.map(|tick| s.tempo.tick_to_samples(tick, s.sample_rate)).filter(|x| c.range().contains(*x)).collect()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
stops.extend([c.start, c.end()].into_iter().chain(sync).chain(notes));
|
||||||
|
}
|
||||||
|
let mut best = None;
|
||||||
|
for &x in &stops {
|
||||||
|
if nearer(x, best) {
|
||||||
|
best = Some(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if transients {
|
||||||
|
// A hit right at a clip start or note is the same stop, not a second one a few samples later.
|
||||||
|
let gap = s.sample_rate.samples(0.01).unsigned_abs();
|
||||||
|
let apart = |x: Samples| stops.iter().all(|b| b.abs_diff(x) > gap);
|
||||||
|
for t in &tracks {
|
||||||
|
for c in t.clips().iter().filter(|c| matches!(c.content, ClipContent::Audio { .. })) {
|
||||||
|
if let Some(x) = transient(s, t.id, c.range(), from, back, best, apart) {
|
||||||
|
best = Some(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transient in `clip` nearest to `from` on its side, if it is nearer than `best`.
|
||||||
|
fn transient(
|
||||||
|
s: &Session,
|
||||||
|
t: TrackId,
|
||||||
|
clip: Range,
|
||||||
|
from: Samples,
|
||||||
|
back: bool,
|
||||||
|
best: Option<Samples>,
|
||||||
|
apart: impl Fn(Samples) -> bool,
|
||||||
|
) -> Option<Samples> {
|
||||||
|
let (lo, hi) = if back {
|
||||||
|
(best.map_or(clip.start, |b| b.saturating_add(1)).max(clip.start), from.min(clip.end))
|
||||||
|
} else {
|
||||||
|
(from.saturating_add(1).max(clip.start), best.unwrap_or(clip.end).min(clip.end))
|
||||||
|
};
|
||||||
|
if lo >= hi {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let block = s.sample_rate.samples(BLOCK_SECS).max(1);
|
||||||
|
let margin = s.sample_rate.samples(0.25);
|
||||||
|
let first = lo.saturating_sub(clip.start) / block;
|
||||||
|
let last = hi.saturating_sub(1).saturating_sub(clip.start) / block;
|
||||||
|
for i in 0..=last.saturating_sub(first).min(SCAN_LIMIT) {
|
||||||
|
let k = if back { last - i } else { first + i };
|
||||||
|
let start = clip.start.saturating_add(k.saturating_mul(block));
|
||||||
|
let b = Range::new(start, start.saturating_add(block).min(clip.end));
|
||||||
|
let around = Range::new(b.start.saturating_sub(margin), b.end.saturating_add(margin));
|
||||||
|
let hits = analysed(s, t, around).into_iter().filter(|x| b.contains(*x) && (lo..hi).contains(x) && apart(*x));
|
||||||
|
if let Some(x) = if back { hits.max() } else { hits.min() } {
|
||||||
|
return Some(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transients of track `t` in `around`, from the cache when nothing there has changed.
|
||||||
|
fn analysed(s: &Session, t: TrackId, around: Range) -> Vec<Samples> {
|
||||||
|
let clips: Vec<Clip> = s.track(t).map_or_else(Vec::new, |tr| tr.clips().iter().filter(|c| c.range().overlaps(&around)).cloned().collect());
|
||||||
|
let audio: Vec<Weak<SourceAudio>> = clips
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| if let ClipContent::Audio { source, .. } = c.content { s.pool.get(source).map(Arc::downgrade) } else { None })
|
||||||
|
.collect();
|
||||||
|
let fx: Vec<(String, f64)> = clips
|
||||||
|
.iter()
|
||||||
|
.flat_map(|c| {
|
||||||
|
let pre = format!("clip_fx.{}.", c.id.0);
|
||||||
|
s.edit.values.range(pre.clone()..).take_while(move |(k, _)| k.starts_with(&pre)).map(|(k, v)| (k.clone(), *v))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let same = |a: &Analysed| {
|
||||||
|
a.track == t
|
||||||
|
&& a.around == around
|
||||||
|
&& a.rate == s.sample_rate
|
||||||
|
&& a.clips == clips
|
||||||
|
&& a.fx == fx
|
||||||
|
&& a.audio.len() == audio.len()
|
||||||
|
&& a.audio.iter().zip(&audio).all(|(x, y)| x.ptr_eq(y))
|
||||||
|
};
|
||||||
|
if let Some(a) = ANALYSED.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|a| same(a)) {
|
||||||
|
return a.hits.clone();
|
||||||
|
}
|
||||||
|
let hits = crate::io::transients_in(s, t, around, 0.5);
|
||||||
|
let mut kept = ANALYSED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
if kept.len() >= CACHE_LIMIT {
|
||||||
|
kept.remove(0);
|
||||||
|
}
|
||||||
|
kept.push(Analysed { track: t, around, rate: s.sample_rate, clips, audio, fx, hits: hits.clone() });
|
||||||
|
hits
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::Engine;
|
||||||
|
use serde_json::json;
|
||||||
|
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||||
|
use soundcraft_midi::{Note, Sequence};
|
||||||
|
use soundcraft_model::{ChannelFormat, Clip, Session, TrackId, TrackKind};
|
||||||
|
use soundcraft_time::{SampleRate, TICKS_PER_QUARTER};
|
||||||
|
|
||||||
|
const SR: f64 = 48_000.0;
|
||||||
|
|
||||||
|
fn at(secs: f64) -> i64 {
|
||||||
|
(secs * SR) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hits(times: &[f64], secs: f64) -> Vec<f32> {
|
||||||
|
let mut v = vec![0.0f32; at(secs) as usize];
|
||||||
|
let mut seed = 7u32;
|
||||||
|
for &t in times {
|
||||||
|
for i in 0..at(0.2) as usize {
|
||||||
|
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||||
|
let noise = (seed >> 8) as f32 / (1u32 << 24) as f32 - 0.5;
|
||||||
|
if let Some(x) = v.get_mut(at(t) as usize + i) {
|
||||||
|
*x = if i == 0 { 0.9 } else { noise * (-(i as f32) / 1500.0).exp() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audio_track(s: &mut Session, name: &str, clips: &[(f64, f64)], audio: Vec<f32>) -> TrackId {
|
||||||
|
let src = crate::io::add_source(s, name, AudioBuffer { sample_rate: 48_000, channels: vec![audio] }, None, FileFormat::Wav);
|
||||||
|
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(name));
|
||||||
|
for &(start, len) in clips {
|
||||||
|
let id = s.new_clip_id();
|
||||||
|
crate::edit::place_clip(s, t, Clip::audio(id, name, src, 0, at(start), at(len)));
|
||||||
|
}
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tab(e: &mut Engine, back: bool, extend: bool) -> (i64, i64) {
|
||||||
|
e.execute("edit.tab", &json!({"back": back, "extend": extend})).unwrap();
|
||||||
|
let r = e.session().edit.selection;
|
||||||
|
(r.start, r.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(e: &mut Engine, back: bool) -> Vec<i64> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
loop {
|
||||||
|
let (x, _) = tab(e, back, false);
|
||||||
|
if out.last() == Some(&x) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out.push(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_steps_through_clip_boundaries_and_sync_points_and_extends_the_selection() {
|
||||||
|
let mut s = Session::new("t", SampleRate::HZ_48000);
|
||||||
|
let t = audio_track(&mut s, "A", &[(1.0, 2.0), (5.0, 3.0)], vec![0.0; at(10.0) as usize]);
|
||||||
|
if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.get_mut(1)) {
|
||||||
|
c.sync_point = at(1.0);
|
||||||
|
}
|
||||||
|
let mut e = Engine::new(s);
|
||||||
|
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0})).unwrap();
|
||||||
|
assert_eq!(walk(&mut e, false), [1.0, 3.0, 5.0, 6.0, 8.0].map(at));
|
||||||
|
assert_eq!(walk(&mut e, true), [6.0, 5.0, 3.0, 1.0].map(at));
|
||||||
|
assert_eq!(e.transport_requests.last(), Some(&crate::TransportRequest::Locate(at(1.0))));
|
||||||
|
|
||||||
|
assert_eq!(tab(&mut e, false, true), (at(1.0), at(3.0)));
|
||||||
|
assert_eq!(tab(&mut e, false, true), (at(1.0), at(5.0)));
|
||||||
|
assert_eq!(tab(&mut e, true, true), (at(1.0), at(3.0)));
|
||||||
|
e.execute("edit.select", &json!({"start": at(5.5), "end": at(7.0)})).unwrap();
|
||||||
|
assert_eq!(tab(&mut e, true, true), (at(5.5), at(6.0)));
|
||||||
|
assert_eq!(tab(&mut e, true, true), (at(5.0), at(6.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_to_transient_finds_the_same_hits_both_ways_across_analysis_blocks() {
|
||||||
|
let planted = [0.5, 1.7, 9.9, 10.1, 15.0, 19.95, 23.0];
|
||||||
|
let mut s = Session::new("t", SampleRate::HZ_48000);
|
||||||
|
let t = audio_track(&mut s, "Drums", &[(2.0, 25.0)], hits(&[&[0.003][..], &planted].concat(), 25.0));
|
||||||
|
let mut e = Engine::new(s);
|
||||||
|
e.execute("options.tab_to_transient", &json!({"value": true})).unwrap();
|
||||||
|
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0})).unwrap();
|
||||||
|
let forward = walk(&mut e, false);
|
||||||
|
let mut backward = walk(&mut e, true);
|
||||||
|
backward.reverse();
|
||||||
|
backward.push(at(27.0));
|
||||||
|
assert_eq!(forward, [&[at(2.0)][..], &backward[1..]].concat());
|
||||||
|
assert_eq!(forward.len(), planted.len() + 2, "{forward:?}");
|
||||||
|
for (found, want) in forward[1..=planted.len()].iter().zip(planted) {
|
||||||
|
assert!((found - at(2.0 + want)).abs() < 256, "{found} vs {want} s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_to_transient_stops_at_the_first_hit_on_any_selected_track_and_at_midi_notes() {
|
||||||
|
let mut s = Session::new("t", SampleRate::HZ_48000);
|
||||||
|
let keys = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||||
|
let mut seq = Sequence::default();
|
||||||
|
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 4 * TICKS_PER_QUARTER, length: TICKS_PER_QUARTER });
|
||||||
|
let id = s.new_clip_id();
|
||||||
|
crate::edit::place_clip(&mut s, keys, Clip::midi(id, "Keys", 0, at(4.0), seq));
|
||||||
|
let a = audio_track(&mut s, "A", &[(0.0, 4.0)], hits(&[1.0, 3.0], 4.0));
|
||||||
|
let b = audio_track(&mut s, "B", &[(0.0, 4.0)], hits(&[2.2], 4.0));
|
||||||
|
let note = |tick| s.tempo.tick_to_samples(tick, s.sample_rate);
|
||||||
|
let (on, off) = (note(4 * TICKS_PER_QUARTER), note(5 * TICKS_PER_QUARTER));
|
||||||
|
let mut e = Engine::new(s);
|
||||||
|
e.execute("edit.select", &json!({"tracks": [keys.0, a.0, b.0], "start": 0})).unwrap();
|
||||||
|
assert_eq!(walk(&mut e, false), [at(4.0)]);
|
||||||
|
e.execute("options.tab_to_transient", &json!({"value": true})).unwrap();
|
||||||
|
e.execute("edit.select", &json!({"start": 0})).unwrap();
|
||||||
|
let stops = walk(&mut e, false);
|
||||||
|
assert_eq!(stops.len(), 6, "{stops:?}");
|
||||||
|
for (i, want) in [(0, 1.0), (2, 2.2), (4, 3.0)] {
|
||||||
|
assert!((stops[i] - at(want)).abs() < 256, "{stops:?}");
|
||||||
|
}
|
||||||
|
assert_eq!([stops[1], stops[3], stops[5]], [on, off, at(4.0)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -163,6 +163,14 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
|
||||||
.collect();
|
.collect();
|
||||||
painter.add(Shape::line(pts, s));
|
painter.add(Shape::line(pts, s));
|
||||||
}
|
}
|
||||||
|
"tab_transient" => {
|
||||||
|
painter.line_segment([p(r, 0.08, 0.5), p(r, 0.3, 0.5)], s);
|
||||||
|
painter.add(Shape::convex_polygon(vec![p(r, 0.42, 0.5), p(r, 0.28, 0.36), p(r, 0.28, 0.64)], c, Stroke::NONE));
|
||||||
|
painter.line_segment([p(r, 0.5, 0.16), p(r, 0.5, 0.84)], s);
|
||||||
|
for (x, h) in [(0.64, 0.32), (0.78, 0.2), (0.92, 0.1)] {
|
||||||
|
painter.line_segment([p(r, x, 0.5 - h), p(r, x, 0.5 + h)], s);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
painter.rect_stroke(r.shrink(r.width() * 0.25), 1.0, s, egui::StrokeKind::Middle);
|
painter.rect_stroke(r.shrink(r.width() * 0.25), 1.0, s, egui::StrokeKind::Middle);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,33 +167,10 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||||
let _ = app.run("edit.nudge", json!({"direction": dir}));
|
let _ = app.run("edit.nudge", json!({"direction": dir}));
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
// Alt+Tab goes back; off macOS the desktop takes Alt+Tab, so Ctrl+Tab does. Cmd+Alt+Tab is Tab to Transient.
|
// Alt+Tab goes back (off macOS the desktop takes Alt+Tab, so Ctrl+Tab does) and Shift extends the selection.
|
||||||
Key::Tab if plain || (m.alt && !m.command) || (!mac && m.ctrl && !m.alt) => {
|
// Cmd+Alt+Tab is Tab to Transient, and the Mac's Control+Tab is Pro Tools' Go to Next Clip.
|
||||||
// Tab to next clip boundary (or transient when enabled).
|
Key::Tab if !(m.command && (mac || m.alt)) && !(mac && m.ctrl) => {
|
||||||
let s = app.engine.session();
|
let _ = app.run("edit.tab", json!({"back": m.alt || (!mac && m.ctrl), "extend": m.shift}));
|
||||||
let at = s.edit.selection.start;
|
|
||||||
let back = !plain;
|
|
||||||
if s.edit.tab_to_transient
|
|
||||||
&& !back
|
|
||||||
&& let Some(t) = s.edit.selected_tracks.first().copied()
|
|
||||||
{
|
|
||||||
let r = soundcraft_time::Range::new(at + 1, s.content_end());
|
|
||||||
if let Some(n) = soundcraft_engine::io::transients_in(s, t, r, 0.5).into_iter().find(|x| *x > at + 64) {
|
|
||||||
let _ = app.run("transport.locate", json!({"at": n}));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut edges: Vec<i64> = s
|
|
||||||
.tracks
|
|
||||||
.iter()
|
|
||||||
.filter(|t| s.edit.selected_tracks.contains(&t.id))
|
|
||||||
.flat_map(|t| t.clips().iter().flat_map(|c| [c.start, c.end()]))
|
|
||||||
.collect();
|
|
||||||
edges.sort_unstable();
|
|
||||||
let next = if back { edges.iter().rev().find(|e| **e < at).copied() } else { edges.iter().find(|e| **e > at).copied() };
|
|
||||||
if let Some(n) = next {
|
|
||||||
let _ = app.run("transport.locate", json!({"at": n}));
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
// The registry's key families "Cmd+Alt+[ / ]", "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read.
|
// The registry's key families "Cmd+Alt+[ / ]", "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read.
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) {
|
||||||
ui.spacing_mut().item_spacing.x = 2.0;
|
ui.spacing_mut().item_spacing.x = 2.0;
|
||||||
let e = app.engine.session().edit.clone();
|
let e = app.engine.session().edit.clone();
|
||||||
let toggles: [(&str, &str, bool, &str); 5] = [
|
let toggles: [(&str, &str, bool, &str); 5] = [
|
||||||
("options.tab_to_transient", "selector", e.tab_to_transient, "Tab to Transient"),
|
("options.tab_to_transient", "tab_transient", e.tab_to_transient, "Tab to Transient"),
|
||||||
("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"),
|
("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"),
|
||||||
("options.link_track_edit", "link", e.link_track_edit, "Link Track and Edit Selection"),
|
("options.link_track_edit", "link", e.link_track_edit, "Link Track and Edit Selection"),
|
||||||
("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"),
|
("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"),
|
||||||
|
|
|
||||||
|
|
@ -232,3 +232,30 @@ fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() {
|
||||||
frame(&mut app, keys(Key::ArrowUp, Modifiers::NONE));
|
frame(&mut app, keys(Key::ArrowUp, Modifiers::NONE));
|
||||||
assert_eq!(pitch(&app), before + 1);
|
assert_eq!(pitch(&app), before + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_tab_extends_the_selection_and_tab_to_transient_stops_on_hits() {
|
||||||
|
for (os, cmd) in OSES {
|
||||||
|
let (ctx, mut app) = rig(os);
|
||||||
|
let sel = |app: &SoundApp| {
|
||||||
|
let r = app.engine.session().edit.selection;
|
||||||
|
(r.start, r.end)
|
||||||
|
};
|
||||||
|
let clip = app.engine.session().track_by_name("Kick").unwrap().clips()[0].range();
|
||||||
|
let mid = clip.start + clip.len() / 2;
|
||||||
|
app.run("edit.select", json!({"tracks": ["Kick"], "start": mid, "end": mid})).unwrap();
|
||||||
|
press(&ctx, &mut app, Key::Tab, Modifiers::SHIFT);
|
||||||
|
assert_eq!(sel(&app), (mid, clip.end), "{os:?}");
|
||||||
|
let back_extend = if os == OperatingSystem::Mac { ALT_SHIFT } else { Modifiers { shift: true, ..cmd } };
|
||||||
|
press(&ctx, &mut app, Key::Tab, back_extend);
|
||||||
|
assert_eq!(sel(&app), (clip.start, clip.end), "{os:?}");
|
||||||
|
|
||||||
|
press(&ctx, &mut app, Key::Tab, Modifiers { alt: true, ..cmd });
|
||||||
|
app.run("edit.select", json!({"start": clip.start})).unwrap();
|
||||||
|
press(&ctx, &mut app, Key::Tab, Modifiers::NONE);
|
||||||
|
let hit = start(&app);
|
||||||
|
assert!(hit > clip.start + 480 && hit < clip.end, "{os:?}: {hit}");
|
||||||
|
press(&ctx, &mut app, Key::Tab, if os == OperatingSystem::Mac { Modifiers::ALT } else { cmd });
|
||||||
|
assert_eq!(start(&app), clip.start, "{os:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue