@pro-gram/flux 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ using Path = System.IO.Path;
16
16
 
17
17
  namespace DrumBuddy
18
18
  {
19
- public sealed class EditorView : Grid, IDisposable
19
+ public sealed partial class EditorView : Grid, IDisposable
20
20
  {
21
21
  private const double RowHeight = 56, RulerHeight = 26;
22
22
  private readonly Window owner;
@@ -35,7 +35,13 @@ namespace DrumBuddy
35
35
  private readonly ScrollViewer scroll, headerScroll;
36
36
  private readonly TextBlock title, positionText, selectionText;
37
37
  private readonly TextBox selectionFrom, selectionTo;
38
- private readonly ComboBox zoom, scope, exportFormat;
38
+ private readonly ComboBox scope, exportFormat;
39
+ private readonly Slider zoomSlider;
40
+ private readonly TextBlock zoomSpan;
41
+ private readonly Button fitSelection;
42
+ private readonly WaveformDetail waveformDetail;
43
+ private bool updatingZoom;
44
+ private DateTime manualViewUntil;
39
45
  private readonly CheckBox separate, selectionOnly, protect;
40
46
  private readonly Button play, undoButton, redoButton, cancel;
41
47
  private readonly Slider volume;
@@ -47,13 +53,21 @@ namespace DrumBuddy
47
53
  private readonly CheckBox snapGrid, snapEdges, gridTriplet;
48
54
  private readonly TextBlock meterText;
49
55
  private readonly WrapPanel gridControls;
50
- private static readonly double[] ZoomLevels = { 5, 10, 20, 40, 80, 160, 320, 640, 1280, 2560 };
51
56
  public Action GridSettingsRequested;
52
57
  public TempoGridOptions GridOptions { get { return session.Grid.Copy(); } }
53
58
  private readonly Grid editorBody;
54
59
  private readonly DockablePane recordingPane;
55
60
  private readonly Button recordButton;
56
- private readonly ToggleButton scissorsTool;
61
+ private bool sliceCursor, clipSelection, selectionMoved;
62
+ private string pendingRegion;
63
+ private double selectionPointerX;
64
+ private readonly HashSet<string> selectedIds = new HashSet<string>();
65
+ private string[] previousIds;
66
+ private bool previousClipSelection;
67
+ private int previousTrack;
68
+ private long previousFrom, previousTo, previousPlayhead;
69
+ private sealed class RegionVisual { public TimelineRegion Region; public Rectangle Body, Glow; }
70
+ private readonly List<RegionVisual> regionVisuals = new List<RegionVisual>();
57
71
  private readonly Button compactOptionsButton;
58
72
  private FrameworkElement exportDetails;
59
73
  private bool compactOptions;
@@ -73,7 +87,7 @@ namespace DrumBuddy
73
87
  private double pixelsPerSecond = 40;
74
88
  private long playhead, from, to;
75
89
  private bool updating, disposed, dirty, tempoEdited;
76
- public bool IsBusy { get { return operation != null || recording; } }
90
+ public bool IsBusy { get { return operation != null || recording || clipDrag != ClipDrag.None; } }
77
91
  public bool Dirty { get { return dirty; } }
78
92
  private bool AllTracks { get { return scope.SelectedIndex == 1; } }
79
93
 
@@ -99,11 +113,9 @@ namespace DrumBuddy
99
113
  editing.Children.Add(WorkspaceUi.Button("Record options", delegate { ShowRecordingOptions(!recordingPane.IsPaneVisible); }));
100
114
  undoButton = WorkspaceUi.Button("Undo", delegate { Undo(false); }); editing.Children.Add(undoButton);
101
115
  redoButton = WorkspaceUi.Button("Redo", delegate { Undo(true); }); editing.Children.Add(redoButton);
102
- editing.Children.Add(WorkspaceUi.Button("✂ Split", delegate { Split(); }));
103
- scissorsTool = new ToggleButton { Content = "Scissors tool", MinHeight = 30, Padding = new Thickness(7, 3, 7, 3), FontSize = 12, Margin = new Thickness(0, 0, 6, 6), ToolTip = "Click a clip to split it. S splits at the playhead; K toggles this tool by default." }; editing.Children.Add(scissorsTool);
104
116
  editing.Children.Add(WorkspaceUi.Button("Delete range", delegate { DeleteRange(); }));
105
117
  scope = new ComboBox { ItemsSource = new[] { "Selected track", "All 16 tracks" }, SelectedIndex = 0, Width = 170, MinHeight = 36, FontSize = 14, Padding = new Thickness(6, 3, 6, 3), Margin = new Thickness(0, 0, 8, 8) }; editing.Children.Add(scope);
106
- scope.SelectionChanged += delegate { DrawCanvas(); };
118
+ scope.SelectionChanged += delegate { if (!updating) { clipSelection = false; selectedIds.Clear(); } DrawCanvas(); };
107
119
  separate = new CheckBox { Content = "Import files on separate tracks", IsChecked = true, FontSize = 14, Margin = new Thickness(4, 0, 0, 8), VerticalAlignment = VerticalAlignment.Center }; editing.Children.Add(separate);
108
120
  compactOptionsButton = WorkspaceUi.Button("Audio / export options", delegate { compactOptions = !compactOptions; UpdateCompactLayout(); }); editing.Children.Add(compactOptionsButton);
109
121
 
@@ -128,23 +140,33 @@ namespace DrumBuddy
128
140
 
129
141
  var range = new WrapPanel { Margin = new Thickness(0, 0, 0, 5) }; tools.Children.Add(range);
130
142
  range.Visibility = Visibility.Collapsed;
131
- editing.Children.Add(WorkspaceUi.Button("Range / zoom", delegate { range.Visibility = range.Visibility == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible; }));
143
+ editing.Children.Add(WorkspaceUi.Button("Range", delegate { range.Visibility = range.Visibility == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible; }));
132
144
  range.Children.Add(WorkspaceUi.Text("Selection, seconds ", 14));
133
145
  selectionFrom = new TextBox { Text = "0.00000", Width = 100, FontSize = 15, Padding = new Thickness(5), Margin = new Thickness(0, 0, 8, 8) }; range.Children.Add(selectionFrom);
134
146
  range.Children.Add(WorkspaceUi.Text("to ", 14));
135
147
  selectionTo = new TextBox { Text = "0.00000", Width = 100, FontSize = 15, Padding = new Thickness(5), Margin = new Thickness(0, 0, 8, 8) }; range.Children.Add(selectionTo);
136
- range.Children.Add(WorkspaceUi.Button("Set range", delegate { if (ReadSelection()) { playhead = from; DrawCanvas(); } }));
137
- range.Children.Add(WorkspaceUi.Text("Zoom ", 14));
138
- zoom = new ComboBox { ItemsSource = ZoomLevels.Select(v => v + " px/s").ToArray(), SelectedIndex = 3, Width = 115, MinHeight = 36, FontSize = 14, Padding = new Thickness(4), Margin = new Thickness(0, 0, 8, 8) };
139
- zoom.SelectionChanged += delegate { if (!updating && zoom.SelectedIndex >= 0) { pixelsPerSecond = ZoomLevels[zoom.SelectedIndex]; DrawCanvas(); EnsurePlayheadVisible(); } }; range.Children.Add(zoom);
148
+ range.Children.Add(WorkspaceUi.Button("Set range", delegate { if (ReadSelection()) { clipSelection = false; selectedIds.Clear(); playhead = from; DrawCanvas(); } }));
140
149
  selectionText = WorkspaceUi.Text("Drag the upper ruler to move the playhead. Drag across a track to select time; blue handles adjust the range. Home goes to the start.", 13); selectionText.Foreground = WorkspaceUi.Color("#526771"); selectionText.Margin = new Thickness(0, 0, 0, 8); tools.Children.Add(selectionText);
141
150
 
151
+ tools.Children.Remove(selectionText);
152
+ var viewControls = new DockPanel { LastChildFill = true, Margin = new Thickness(0, 0, 0, 3) }; tools.Children.Add(viewControls);
153
+ var zoomTools = new StackPanel { Orientation = Orientation.Horizontal, Name = "EditorZoomControls" }; DockPanel.SetDock(zoomTools, Dock.Right); viewControls.Children.Add(zoomTools);
154
+ zoomTools.Children.Add(WorkspaceUi.Text("Zoom ", 12));
155
+ var zoomOut = WorkspaceUi.Button("-", delegate { ZoomStep(.5); }); zoomOut.Name = "EditorZoomOut"; zoomOut.Width = 28; zoomOut.MinHeight = 26; zoomOut.Padding = new Thickness(4, 1, 4, 1); zoomOut.ToolTip = "Zoom out (Ctrl+-)"; zoomTools.Children.Add(zoomOut);
156
+ zoomSlider = new Slider { Minimum = Math.Log(TimelineZoom.Minimum, 2), Maximum = Math.Log(TimelineZoom.Maximum, 2), Value = Math.Log(pixelsPerSecond, 2), Width = 96, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 6), IsMoveToPointEnabled = true, ToolTip = "Timeline zoom" };
157
+ zoomSlider.ValueChanged += delegate { if (!updatingZoom) SetZoom(Math.Pow(2, zoomSlider.Value), ZoomAnchor()); }; zoomTools.Children.Add(zoomSlider);
158
+ var zoomIn = WorkspaceUi.Button("+", delegate { ZoomStep(2); }); zoomIn.Name = "EditorZoomIn"; zoomIn.Width = 28; zoomIn.MinHeight = 26; zoomIn.Padding = new Thickness(4, 1, 4, 1); zoomIn.ToolTip = "Zoom in (Ctrl+=)"; zoomTools.Children.Add(zoomIn);
159
+ zoomSpan = WorkspaceUi.Text("", 12); zoomSpan.Width = 72; zoomSpan.Margin = new Thickness(0, 0, 6, 6); zoomSpan.TextAlignment = TextAlignment.Right; zoomSpan.TextWrapping = TextWrapping.NoWrap; zoomTools.Children.Add(zoomSpan);
160
+ var fitProject = WorkspaceUi.Button("Fit", delegate { FitZoom(false); }); fitProject.Name = "EditorZoomFit"; fitProject.MinHeight = 26; fitProject.Padding = new Thickness(7, 1, 7, 1); fitProject.ToolTip = "Fit project (Ctrl+0)"; zoomTools.Children.Add(fitProject);
161
+ fitSelection = WorkspaceUi.Button("Selection", delegate { FitZoom(true); }); fitSelection.Name = "EditorZoomSelection"; fitSelection.MinHeight = 26; fitSelection.Padding = new Thickness(7, 1, 7, 1); fitSelection.ToolTip = "Fit selected time range (Ctrl+Shift+0)"; zoomTools.Children.Add(fitSelection);
162
+ selectionText.Margin = new Thickness(0, 0, 8, 6); selectionText.TextWrapping = TextWrapping.NoWrap; viewControls.Children.Add(selectionText);
163
+
142
164
  var body = new Grid { Background = WorkspaceUi.Color("#FFFFFF") }; SetRow(body, 2); editorBody.Children.Add(body);
143
165
  body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(180) }); body.ColumnDefinitions.Add(new ColumnDefinition());
144
166
  headers = new StackPanel();
145
167
  headerScroll = new ScrollViewer { Content = headers, VerticalScrollBarVisibility = ScrollBarVisibility.Hidden, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled };
146
168
  body.Children.Add(headerScroll);
147
- timeline = new Canvas { Background = WorkspaceUi.Color("#F7FAF9"), ClipToBounds = true, Cursor = Cursors.Cross, Focusable = true };
169
+ timeline = new Canvas { Background = WorkspaceUi.Color("#F7FAF9"), ClipToBounds = true, Cursor = Cursors.Arrow, Focusable = true };
148
170
  playLine = new Line { Stroke = WorkspaceUi.Color("#D0772C"), StrokeThickness = 2, IsHitTestVisible = false };
149
171
  selectionOverlay = new Rectangle { Fill = WorkspaceUi.Color("#5596C8"), Opacity = .2, IsHitTestVisible = false };
150
172
  selectionStartLine = new Line { Stroke = WorkspaceUi.Color("#277FA5"), StrokeThickness = 1.15, IsHitTestVisible = false };
@@ -153,18 +175,29 @@ namespace DrumBuddy
153
175
  scroll = new ScrollViewer { Content = timeline, HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, CanContentScroll = false };
154
176
  SetColumn(scroll, 1); body.Children.Add(scroll);
155
177
  scroll.ScrollChanged += delegate(object sender, ScrollChangedEventArgs e) { headerScroll.ScrollToVerticalOffset(scroll.VerticalOffset); if (Math.Abs(e.HorizontalChange) > .01 || Math.Abs(e.ViewportWidthChange) > .01) DrawCanvas(); };
178
+ scroll.PreviewMouseWheel += delegate(object sender, MouseWheelEventArgs e) {
179
+ if ((Keyboard.Modifiers & ModifierKeys.Control) != 0) {
180
+ if (gesture.Mode == TimelineDrag.None) SetZoom(pixelsPerSecond * Math.Pow(2, e.Delta / 480.0), e.GetPosition(scroll).X);
181
+ e.Handled = true;
182
+ }
183
+ else if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0) { manualViewUntil = DateTime.UtcNow.AddSeconds(2); scroll.ScrollToHorizontalOffset(scroll.HorizontalOffset - e.Delta); e.Handled = true; }
184
+ };
156
185
  headerScroll.PreviewMouseWheel += delegate(object sender, MouseWheelEventArgs e) { scroll.ScrollToVerticalOffset(scroll.VerticalOffset - e.Delta); e.Handled = true; };
157
186
  timeline.MouseLeftButtonDown += BeginSelection;
158
187
  timeline.MouseMove += delegate(object sender, MouseEventArgs e) {
159
- if (gesture.Mode == TimelineDrag.None) return;
160
- gesture.Move(PointerFrame(e.GetPosition(timeline).X)); ApplyGesture();
188
+ var point = e.GetPosition(timeline);
189
+ if (clipDrag != ClipDrag.None) { PreviewClipDrag(point); return; }
190
+ if (gesture.Mode == TimelineDrag.None) { UpdateClipCursor(point); return; }
191
+ MovePointer(point.X);
161
192
  };
162
193
  timeline.MouseLeftButtonUp += delegate(object sender, MouseButtonEventArgs e) {
194
+ if (clipDrag != ClipDrag.None) { PreviewClipDrag(e.GetPosition(timeline)); FinishClipDrag(); timeline.ReleaseMouseCapture(); e.Handled = true; return; }
163
195
  if (gesture.Mode == TimelineDrag.None) return;
164
- gesture.End(PointerFrame(e.GetPosition(timeline).X)); ApplyGesture(); DrawCanvas(); timeline.ReleaseMouseCapture(); e.Handled = true;
196
+ EndPointer(e.GetPosition(timeline).X); timeline.ReleaseMouseCapture(); e.Handled = true;
165
197
  };
166
- timeline.LostMouseCapture += delegate { if (gesture.Mode != TimelineDrag.None) { gesture.Finish(); ApplyGesture(); } };
198
+ timeline.LostMouseCapture += delegate { if (clipDrag != ClipDrag.None) CancelClipDrag(); if (gesture.Mode != TimelineDrag.None) { gesture.Finish(); ApplyGesture(); RefreshClipBounds(); DrawCanvas(); } };
167
199
 
200
+ InitializeClipTools(editing);
168
201
  var bottom = new StackPanel { Margin = new Thickness(0, 12, 0, 0) }; SetRow(bottom, 3); editorBody.Children.Add(bottom);
169
202
  var transport = new WrapPanel(); bottom.Children.Add(transport);
170
203
  play = WorkspaceUi.Button("Play", delegate { PlayPause(); }); play.MinWidth = 90; transport.Children.Add(play);
@@ -182,9 +215,11 @@ namespace DrumBuddy
182
215
  protect = new CheckBox { Content = "Prevent clipping (PCM)", IsChecked = true, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 15, 0) }; exports.Children.Add(protect);
183
216
  selectionOnly = new CheckBox { Content = "Selection only", FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 15, 0) }; exports.Children.Add(selectionOnly);
184
217
  AddFileButton(exports, "Export mix…", async delegate { await ExportMix(); });
218
+ ArrangeCompactChrome(top, editing, range, viewControls, zoomTools, bottom, transport, exports);
185
219
  timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(80) };
186
- timer.Tick += delegate { if (output.Playing || output.Paused) { playhead = (long)Math.Round(output.Position * Audio.Rate); UpdateTransport(); UpdatePlayLine(); if (output.Playing) EnsurePlayheadVisible(); } }; timer.Start();
187
- BuildHeaders(); DrawCanvas();
220
+ timer.Tick += delegate { if (output.Playing || output.Paused) { playhead = (long)Math.Round(output.Position * Audio.Rate); UpdateTransport(); UpdatePlayLine(); if (output.Playing && DateTime.UtcNow >= manualViewUntil) EnsurePlayheadVisible(); } }; timer.Start();
221
+ waveformDetail = new WaveformDetail(Dispatcher, delegate { if (!disposed) DrawCanvas(); });
222
+ UpdateSelectionText(); BuildHeaders(); DrawCanvas();
188
223
  recordingPane = new DockablePane(editorBody, null, "Record options", false, false, 260) { MinimumMainHeight = 300 }; Children.Add(recordingPane);
189
224
  editorBody.SizeChanged += delegate { UpdateCompactLayout(); };
190
225
  }
@@ -193,11 +228,14 @@ namespace DrumBuddy
193
228
  {
194
229
  double width = editorBody.ActualWidth > 0 ? editorBody.ActualWidth : 1280;
195
230
  bool compact = width < 1000 || (editorBody.ActualHeight > 0 && editorBody.ActualHeight < 480);
196
- compactOptionsButton.Visibility = compact ? Visibility.Visible : Visibility.Collapsed;
197
- outputPicker.Visibility = exportDetails.Visibility = separate.Visibility = compact && !compactOptions ? Visibility.Collapsed : Visibility.Visible;
231
+ compactOptionsButton.Visibility = Visibility.Visible;
232
+ outputPicker.Visibility = separate.Visibility = Visibility.Visible;
233
+ exportDetails.Visibility = compactOptions ? Visibility.Visible : Visibility.Collapsed;
198
234
  gridTriplet.Visibility = rulerMode.Visibility = meterText.Visibility = compact ? Visibility.Collapsed : Visibility.Visible;
199
- title.FontSize = compact ? 20 : 24;
200
- selectionText.TextWrapping = compact ? TextWrapping.NoWrap : TextWrapping.Wrap;
235
+ title.FontSize = 14;
236
+ zoomSpan.Visibility = width < 700 ? Visibility.Collapsed : Visibility.Visible;
237
+ zoomSlider.Width = width < 700 ? 64 : 80;
238
+ selectionText.TextWrapping = TextWrapping.NoWrap;
201
239
  selectionText.TextTrimming = TextTrimming.CharacterEllipsis;
202
240
  }
203
241
 
@@ -246,13 +284,46 @@ namespace DrumBuddy
246
284
  private long FrameAt(double x) { return Math.Max(0, Math.Min(EditorSession.MaxFrame, (long)Math.Round(x / pixelsPerSecond * Audio.Rate))); }
247
285
  private long PointerFrame(double x)
248
286
  { return TimelineGesture.FrameAt(x, pixelsPerSecond, session.Regions.SelectMany(r => new[] { r.Start, r.End }), (Keyboard.Modifiers & ModifierKeys.Alt) != 0, session.Grid, InterfacePreferences.Current.SnapToGrid, InterfacePreferences.Current.SnapToEdges); }
249
- private void ApplyGesture() { playhead = gesture.Playhead; from = gesture.From; to = gesture.To; UpdateSelectionText(); UpdateSelectionOverlay(); UpdatePlayLine(); UpdateTransport(); }
287
+ private void ApplyGesture() { playhead = gesture.Playhead; from = gesture.From; to = gesture.To; UpdateSelectionText(); UpdateSelectionOverlay(); RefreshRegionHighlights(); UpdatePlayLine(); UpdateTransport(); }
250
288
  private double X(long frame) { return frame / (double)Audio.Rate * pixelsPerSecond; }
289
+ private double ViewportWidth { get { return scroll != null && scroll.ViewportWidth > 0 ? scroll.ViewportWidth : 900; } }
290
+ private double ZoomAnchor()
291
+ {
292
+ double x = X(playhead) - scroll.HorizontalOffset;
293
+ return x >= 0 && x <= ViewportWidth ? x : ViewportWidth / 2;
294
+ }
295
+ private void ZoomStep(double factor) { SetZoom(pixelsPerSecond * factor, ZoomAnchor()); }
296
+ private void SetZoom(double value, double pointer)
297
+ {
298
+ if (scroll == null || gesture.Mode != TimelineDrag.None || clipDrag != ClipDrag.None || disposed) return;
299
+ double scale = TimelineZoom.Clamp(value), at = Math.Max(0, Math.Min(ViewportWidth, pointer));
300
+ double offset = TimelineZoom.Offset(pixelsPerSecond, scale, scroll.HorizontalOffset, at);
301
+ pixelsPerSecond = scale; manualViewUntil = DateTime.UtcNow.AddSeconds(2);
302
+ DrawCanvas(); scroll.UpdateLayout(); scroll.ScrollToHorizontalOffset(offset); scroll.UpdateLayout(); DrawCanvas();
303
+ }
304
+ private void FitZoom(bool selected)
305
+ {
306
+ if (gesture.Mode != TimelineDrag.None) return;
307
+ if (selected && to <= from) return;
308
+ long start = selected ? from : 0, end = selected ? to : session.End > 0 ? session.End : Audio.Rate * 15L;
309
+ SetZoom(TimelineZoom.Fit(end - start, ViewportWidth), 0);
310
+ scroll.ScrollToHorizontalOffset(Math.Max(0, X(start) - 16)); scroll.UpdateLayout(); DrawCanvas();
311
+ }
312
+ private void RefreshZoomControls()
313
+ {
314
+ updatingZoom = true; zoomSlider.Value = Math.Log(pixelsPerSecond, 2); updatingZoom = false;
315
+ double span = ViewportWidth / pixelsPerSecond;
316
+ zoomSpan.Text = span >= 3600 ? (span / 3600).ToString("0.#", CultureInfo.InvariantCulture) + " h" : span >= 60 ? (span / 60).ToString("0.#", CultureInfo.InvariantCulture) + " min" : span < 1 ? (span * 1000).ToString("0.##", CultureInfo.InvariantCulture) + " ms" : span.ToString("0.##", CultureInfo.InvariantCulture) + " s";
317
+ fitSelection.IsEnabled = to > from;
318
+ }
251
319
  private void UpdateSelectionText()
252
320
  {
253
321
  selectionFrom.Text = (from / (double)Audio.Rate).ToString("0.00000", CultureInfo.InvariantCulture);
254
322
  selectionTo.Text = (to / (double)Audio.Rate).ToString("0.00000", CultureInfo.InvariantCulture);
255
323
  selectionText.Text = "Track " + (track + 1) + " | Selection " + (from / (double)Audio.Rate).ToString("0.00000", CultureInfo.InvariantCulture) + " – " + (to / (double)Audio.Rate).ToString("0.00000", CultureInfo.InvariantCulture) + " s";
324
+ if (fitSelection != null) fitSelection.IsEnabled = to > from;
325
+ if (clipSelection) selectionText.Text = selectedIds.Count == 1 ? "1 clip selected" : selectedIds.Count + " clips selected";
326
+ selectionText.Visibility = to > from || clipSelection ? Visibility.Visible : Visibility.Collapsed;
256
327
  }
257
328
  private bool ReadSelection()
258
329
  {
@@ -265,24 +336,75 @@ namespace DrumBuddy
265
336
  }
266
337
  private void BeginSelection(object sender, MouseButtonEventArgs e)
267
338
  {
268
- if (IsBusy) return; Stop(); timeline.Focus(); var point = e.GetPosition(timeline); long frame = PointerFrame(point.X);
339
+ if (IsBusy) return; Stop(); timeline.Focus();
340
+ BeginPointer(e.GetPosition(timeline), (Keyboard.Modifiers & ModifierKeys.Shift) != 0, e.ClickCount);
341
+ if (gesture.Mode != TimelineDrag.None || clipDrag != ClipDrag.None) timeline.CaptureMouse(); e.Handled = true;
342
+ }
343
+ private void BeginPointer(Point point, bool additive, int clicks)
344
+ {
345
+ previousIds = selectedIds.ToArray(); previousClipSelection = clipSelection; previousTrack = track;
346
+ previousFrom = from; previousTo = to; previousPlayhead = playhead;
347
+ long frame = PointerFrame(point.X); pendingRegion = null; selectionMoved = false;
269
348
  if (point.Y < RulerHeight) {
270
349
  var mode = TimelineGesture.RulerHit(point.X, point.Y, X(from), X(to), to > from);
271
- gesture.Begin(mode, frame, playhead, from, to); ApplyGesture(); timeline.CaptureMouse(); e.Handled = true; return;
350
+ if (mode != TimelineDrag.Playhead) { clipSelection = false; selectedIds.Clear(); }
351
+ gesture.Begin(mode, frame, playhead, from, to); ApplyGesture(); return;
272
352
  }
273
353
  track = Math.Max(0, Math.Min(15, (int)((point.Y - RulerHeight) / RowHeight)));
274
- if (scissorsTool.IsChecked == true) {
275
- playhead = frame;
276
- Change(delegate { int before = session.Regions.Count; TimelineEdits.Split(session, track, false, frame); return session.Regions.Count != before; }, "Audio split at the cursor.");
277
- DrawCanvas(); e.Handled = true; return;
354
+ var region = RegionAt(point);
355
+ if (sliceCursor) { playhead = frame; Change(() => TimelineEdits.Split(session, track, false, frame), "Split at the cursor."); return; }
356
+ if (region != null && !additive && clicks == 1) { var tool = ClipToolAt(region, point); if (tool != ClipDrag.None) { BeginClipDrag(tool, region, point); return; } }
357
+ if (additive && region != null) { playhead = frame; SelectRegion(region, true); BuildHeaders(); DrawCanvas(); return; }
358
+ if (clicks == 2 && region != null) { playhead = region.Start; SelectRegion(region, false); BuildHeaders(); DrawCanvas(); return; }
359
+ selectionPointerX = point.X; pendingRegion = region == null ? null : region.Id;
360
+ selectedIds.Clear(); clipSelection = region != null; if (region != null) selectedIds.Add(region.Id);
361
+ gesture.Begin(TimelineDrag.Selection, frame, playhead, from, to); gesture.Playhead = frame;
362
+ ApplyGesture(); BuildHeaders(); DrawCanvas();
363
+ }
364
+ private void MovePointer(double x)
365
+ {
366
+ if (gesture.Mode == TimelineDrag.Selection && Math.Abs(x - selectionPointerX) >= 3) {
367
+ if (!selectionMoved) { selectionMoved = true; clipSelection = false; selectedIds.Clear(); DrawCanvas(); }
278
368
  }
279
- if (e.ClickCount == 2) {
280
- long rawFrame = FrameAt(point.X);
281
- var region = session.Regions.LastOrDefault(r => r.Track == track && r.Start <= rawFrame && r.End > rawFrame);
282
- if (region != null) { from = playhead = region.Start; to = region.End; UpdateSelectionText(); BuildHeaders(); DrawCanvas(); e.Handled = true; return; }
369
+ gesture.Move(PointerFrame(x)); ApplyGesture();
370
+ }
371
+ private void EndPointer(double x)
372
+ {
373
+ var mode = gesture.Mode; MovePointer(x); gesture.End(PointerFrame(x)); ApplyGesture();
374
+ if (mode == TimelineDrag.Selection && !selectionMoved && pendingRegion != null) {
375
+ var region = session.Regions.FirstOrDefault(r => r.Id == pendingRegion); if (region != null) SelectRegion(region, false);
283
376
  }
284
- gesture.Begin(TimelineDrag.Selection, frame, playhead, from, to); ApplyGesture();
285
- timeline.CaptureMouse(); UpdateSelectionText(); BuildHeaders(); DrawCanvas(); e.Handled = true;
377
+ else if (mode == TimelineDrag.Selection && to > from) playhead = from;
378
+ pendingRegion = null; UpdateSelectionText(); BuildHeaders(); DrawCanvas();
379
+ }
380
+ private void SelectRegion(TimelineRegion region, bool additive)
381
+ {
382
+ if (!additive) selectedIds.Clear();
383
+ if (!selectedIds.Add(region.Id)) selectedIds.Remove(region.Id);
384
+ clipSelection = selectedIds.Count > 0;
385
+ var selected = session.Regions.Where(r => selectedIds.Contains(r.Id)).ToArray();
386
+ from = selected.Length == 0 ? playhead : selected.Min(r => r.Start);
387
+ to = selected.Length == 0 ? playhead : selected.Max(r => r.End); UpdateSelectionText();
388
+ }
389
+ private bool RegionSelected(TimelineRegion region)
390
+ { return clipSelection ? selectedIds.Contains(region.Id) : to > from && region.Start < to && region.End > from && (AllTracks || region.Track == track); }
391
+ private void RefreshRegionHighlights()
392
+ {
393
+ foreach (var visual in regionVisuals) {
394
+ bool selected = RegionSelected(visual.Region), muted = visual.Region.Muted || session.Tracks[visual.Region.Track].Mute;
395
+ visual.Body.Fill = WorkspaceUi.Color(selected ? "#DAD5FA" : muted ? "#CBD8D4" : "#B9DDD2");
396
+ visual.Body.Stroke = WorkspaceUi.Color(selected ? "#7359C7" : "#5E9D89");
397
+ visual.Body.StrokeThickness = selected ? 1.7 : 1; visual.Body.Tag = selected ? "SelectedRegion" : null;
398
+ visual.Glow.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
399
+ }
400
+ }
401
+ private void RefreshClipBounds()
402
+ {
403
+ if (!clipSelection) return;
404
+ var picked = session.Regions.Where(r => selectedIds.Contains(r.Id)).ToArray();
405
+ if (picked.Length == 0) { selectedIds.Clear(); clipSelection = false; from = to = playhead; }
406
+ else { from = picked.Min(r => r.Start); to = picked.Max(r => r.End); if (!picked.Any(r => r.Track == track)) track = picked[0].Track; }
407
+ UpdateSelectionText();
286
408
  }
287
409
  private void BuildHeaders()
288
410
  {
@@ -309,34 +431,30 @@ namespace DrumBuddy
309
431
  }
310
432
  private void DrawCanvas()
311
433
  {
312
- if (timeline == null) return;
313
- timeline.Children.Clear(); timeline.Height = RulerHeight + RowHeight * 16;
434
+ if (timeline == null || disposed) return;
435
+ timeline.Children.Clear(); regionVisuals.Clear(); timeline.Height = RulerHeight + RowHeight * 16;
314
436
  timeline.Width = Math.Max(Math.Max(900, scroll.ViewportWidth), X(Math.Max(session.End, Math.Max(to, playhead)) + Audio.Rate * 15L));
315
437
  double left = scroll.HorizontalOffset, right = left + Math.Max(900, scroll.ViewportWidth);
438
+ waveformDetail.BeginView(left.ToString("R", CultureInfo.InvariantCulture) + "|" + right.ToString("R", CultureInfo.InvariantCulture) + "|" + pixelsPerSecond.ToString("R", CultureInfo.InvariantCulture));
316
439
  for (int i = 0; i < 16; i++) {
317
440
  var row = new Rectangle { Width = Math.Max(900, scroll.ViewportWidth), Height = RowHeight, Fill = WorkspaceUi.Color(i == track ? "#F0F8F5" : i % 2 == 0 ? "#FCFDFC" : "#F4F8F6"), Stroke = WorkspaceUi.Color("#E0E8E5"), StrokeThickness = .5, IsHitTestVisible = false };
318
441
  Canvas.SetLeft(row, left); Canvas.SetTop(row, RulerHeight + i * RowHeight); timeline.Children.Add(row);
319
442
  }
320
443
  foreach (var region in session.Regions.Where(r => X(r.End) >= left && X(r.Start) <= right)) DrawRegion(region, left, right);
321
444
  RefreshGridToolbar();
445
+ RefreshZoomControls();
446
+ bool visibleLabel = false;
322
447
  if (session.Grid.MusicalRuler) foreach (var tick in TempoGrid.VisibleTicks(session.Grid, left, right - left, pixelsPerSecond)) {
323
448
  double x = X(tick.Frame);
324
449
  timeline.Children.Add(new Line { X1 = x, X2 = x, Y1 = tick.Kind == 0 ? RulerHeight : 0, Y2 = timeline.Height,
325
450
  Stroke = WorkspaceUi.Color(tick.Kind == 2 ? "#386BA8" : tick.Kind == 1 ? "#D6E1DF" : "#E0E8E5"), Opacity = tick.Kind == 2 ? .48 : tick.Kind == 1 ? .7 : .45, StrokeThickness = tick.Kind == 2 ? 1 : .5, IsHitTestVisible = false });
326
- if (tick.Label != null) { var label = WorkspaceUi.Text(tick.Label, 11); label.IsHitTestVisible = false; Canvas.SetLeft(label, x + 4); Canvas.SetTop(label, 3); timeline.Children.Add(label); }
327
- }
328
- else {
329
- double spacing = pixelsPerSecond >= 80 ? 1 : pixelsPerSecond >= 20 ? 5 : 10;
330
- for (double second = Math.Floor(left / pixelsPerSecond / spacing) * spacing; second * pixelsPerSecond < right; second += spacing) {
331
- double x = second * pixelsPerSecond;
332
- var line = new Line { X1 = x, X2 = x, Y1 = 0, Y2 = timeline.Height, Stroke = WorkspaceUi.Color("#DAE5E0"), StrokeThickness = 1, IsHitTestVisible = false }; timeline.Children.Add(line);
333
- var label = WorkspaceUi.Text(WorkspaceUi.Clock(second), 11); label.IsHitTestVisible = false; Canvas.SetLeft(label, x + 4); Canvas.SetTop(label, 3); timeline.Children.Add(label);
334
- }
451
+ if (tick.Label != null) { if (x >= left && x < left + ViewportWidth) visibleLabel = true; var label = WorkspaceUi.Text(tick.Label, 11); label.IsHitTestVisible = false; Canvas.SetLeft(label, x + 4); Canvas.SetTop(label, 3); timeline.Children.Add(label); }
335
452
  }
453
+ if (!session.Grid.MusicalRuler || !visibleLabel) DrawTimeRuler(left, right);
336
454
  timeline.Children.Add(selectionOverlay); timeline.Children.Add(selectionStartLine); timeline.Children.Add(selectionEndLine); timeline.Children.Add(selectionStartHandle); timeline.Children.Add(selectionEndHandle); UpdateSelectionOverlay();
337
455
  playLine.Y1 = 0; playLine.Y2 = timeline.Height; timeline.Children.Add(playLine); UpdatePlayLine(); UpdateTransport();
338
456
  undoButton.IsEnabled = !IsBusy && undo.Count > 0; redoButton.IsEnabled = !IsBusy && redo.Count > 0;
339
- title.Text = "Editor · " + session.Name + (dirty ? " *" : "");
457
+ title.Text = session.Name + (dirty ? " *" : "");
340
458
  }
341
459
  private void UpdateSelectionOverlay()
342
460
  {
@@ -345,10 +463,21 @@ namespace DrumBuddy
345
463
  foreach (var item in new UIElement[] { selectionOverlay, selectionStartLine, selectionEndLine, selectionStartHandle, selectionEndHandle }) item.Visibility = visibility;
346
464
  if (to <= from) return;
347
465
  double left = scroll.HorizontalOffset, right = left + Math.Max(900, scroll.ViewportWidth), a = Math.Max(left, X(from)), b = Math.Min(right, X(to));
466
+ if (clipSelection) selectionOverlay.Visibility = Visibility.Collapsed;
348
467
  selectionOverlay.Width = Math.Max(0, b - a); selectionOverlay.Height = AllTracks ? RowHeight * 16 : RowHeight;
349
468
  Canvas.SetLeft(selectionOverlay, a); Canvas.SetTop(selectionOverlay, RulerHeight + (AllTracks ? 0 : track * RowHeight));
350
469
  UpdateRangeStick(selectionStartLine, selectionStartHandle, X(from), true); UpdateRangeStick(selectionEndLine, selectionEndHandle, X(to), false);
351
470
  }
471
+ private void DrawTimeRuler(double left, double right)
472
+ {
473
+ double spacing = TimelineZoom.SecondStep(pixelsPerSecond);
474
+ for (double second = Math.Floor(left / pixelsPerSecond / spacing) * spacing; second * pixelsPerSecond < right; second += spacing) {
475
+ double x = second * pixelsPerSecond;
476
+ timeline.Children.Add(new Line { X1 = x, X2 = x, Y1 = 0, Y2 = timeline.Height, Stroke = WorkspaceUi.Color("#DAE5E0"), StrokeThickness = .6, Opacity = .65, IsHitTestVisible = false });
477
+ string time = spacing >= 1 ? WorkspaceUi.Clock(second) : second.ToString("F" + Math.Max(1, (int)Math.Ceiling(-Math.Log10(spacing))), CultureInfo.InvariantCulture) + " s";
478
+ var label = WorkspaceUi.Text(time, 11); label.IsHitTestVisible = false; Canvas.SetLeft(label, x + 4); Canvas.SetTop(label, 3); timeline.Children.Add(label);
479
+ }
480
+ }
352
481
  private void UpdateRangeStick(Line line, Polygon handle, double x, bool start)
353
482
  {
354
483
  line.X1 = line.X2 = x; line.Y1 = 14; line.Y2 = timeline.Height;
@@ -357,26 +486,34 @@ namespace DrumBuddy
357
486
  private void DrawRegion(TimelineRegion region, double left, double right)
358
487
  {
359
488
  double x = Math.Max(left, X(region.Start)), end = Math.Min(right, X(region.End)), y = RulerHeight + region.Track * RowHeight + 3;
360
- double width = Math.Max(2, end - x); bool muted = session.Tracks[region.Track].Mute;
361
- var rectangle = new Rectangle { Width = width, Height = RowHeight - 6, RadiusX = 5, RadiusY = 5, Fill = WorkspaceUi.Color(muted ? "#CBD8D4" : "#B9DDD2"), Stroke = WorkspaceUi.Color("#5E9D89"), StrokeThickness = 1, IsHitTestVisible = false };
489
+ double width = Math.Max(2, end - x); bool muted = region.Muted || session.Tracks[region.Track].Mute, selected = RegionSelected(region);
490
+ var rectangle = new Rectangle { Width = width, Height = RowHeight - 6, RadiusX = 5, RadiusY = 5, Fill = WorkspaceUi.Color(selected ? "#DAD5FA" : muted ? "#CBD8D4" : "#B9DDD2"), Stroke = WorkspaceUi.Color(selected ? "#7359C7" : "#5E9D89"), StrokeThickness = selected ? 1.7 : 1, Tag = selected ? "SelectedRegion" : null, IsHitTestVisible = false };
491
+ var glow = new Rectangle { Width = width + 2, Height = RowHeight - 4, RadiusX = 6, RadiusY = 6, Stroke = WorkspaceUi.Color("#7359C7"), StrokeThickness = 4, Opacity = .28, IsHitTestVisible = false, Visibility = selected ? Visibility.Visible : Visibility.Collapsed }; Canvas.SetLeft(glow, x - 1); Canvas.SetTop(glow, y - 1); timeline.Children.Add(glow);
362
492
  Canvas.SetLeft(rectangle, x); Canvas.SetTop(rectangle, y); timeline.Children.Add(rectangle);
493
+ regionVisuals.Add(new RegionVisual { Region = region, Body = rectangle, Glow = glow });
363
494
  Clip clip;
364
495
  if (assets.TryGetValue(region.Source, out clip) && clip.Waveform != null && width > 2) {
365
- int points = Math.Max(2, Math.Min(400, (int)(width / 2))); var wave = new Polygon { Fill = WorkspaceUi.Color("#438B77"), Opacity = .7, IsHitTestVisible = false };
496
+ long sourceStart = region.SourceStart + (long)Math.Floor((x - X(region.Start)) / pixelsPerSecond * Audio.Rate);
497
+ long sourceEnd = Math.Min(region.SourceStart + region.Length, region.SourceStart + (long)Math.Ceiling((end - X(region.Start)) / pixelsPerSecond * Audio.Rate));
498
+ var detail = pixelsPerSecond * clip.Duration / clip.Waveform.Length > 1 ? waveformDetail.Get(clip.Cache, sourceStart, sourceEnd, Math.Max(2, Math.Min(2000, (int)Math.Ceiling(width)))) : null;
499
+ int points = detail == null ? Math.Max(2, Math.Min(400, (int)(width / 2))) : detail.Low.Length;
500
+ var wave = new Polygon { Fill = WorkspaceUi.Color("#438B77"), Stroke = detail == null ? null : WorkspaceUi.Color("#438B77"), StrokeThickness = .8, Opacity = .7, IsHitTestVisible = false, Tag = detail == null ? "WaveformOverview" : "WaveformDetail" };
366
501
  double sourceFrames = Math.Max(1, clip.Duration * Audio.Rate), scale = Math.Max(1, clip.Peak);
367
502
  for (int side = 0; side < 2; side++) for (int p = 0; p < points; p++) {
368
503
  int j = side == 0 ? p : points - p - 1; double px = x + j * width / (points - 1);
369
504
  double sourceFrame = region.SourceStart + (px - X(region.Start)) / pixelsPerSecond * Audio.Rate;
370
505
  int bin = Math.Max(0, Math.Min(clip.Waveform.Length - 1, (int)(sourceFrame / sourceFrames * clip.Waveform.Length)));
371
- double amplitude = clip.Waveform[bin] / scale * RowHeight * .22;
372
- wave.Points.Add(new Point(px, y + RowHeight * .65 + (side == 0 ? -amplitude : amplitude)));
506
+ double value = detail == null ? clip.Waveform[bin] * (side == 0 ? 1 : -1) : side == 0 ? detail.High[j] : detail.Low[j];
507
+ value *= EditorTools.Envelope(region, (long)Math.Round(sourceFrame - region.SourceStart));
508
+ wave.Points.Add(new Point(px, y + RowHeight * .65 - value / scale * RowHeight * .22));
373
509
  }
374
510
  timeline.Children.Add(wave);
375
511
  }
376
512
  if (width > 45) {
377
- var label = WorkspaceUi.Text(region.Name, 12); label.MaxWidth = Math.Max(1, width - 10); label.TextWrapping = TextWrapping.NoWrap; label.TextTrimming = TextTrimming.CharacterEllipsis; label.IsHitTestVisible = false;
513
+ var label = WorkspaceUi.Text(region.Name + (region.Muted ? " · muted" : ""), 12); label.MaxWidth = Math.Max(1, width - 10); label.TextWrapping = TextWrapping.NoWrap; label.TextTrimming = TextTrimming.CharacterEllipsis; label.IsHitTestVisible = false;
378
514
  Canvas.SetLeft(label, x + 5); Canvas.SetTop(label, y + 3); timeline.Children.Add(label);
379
515
  }
516
+ DrawClipFades(region, left, right, selected);
380
517
  }
381
518
  private void UpdatePlayLine() { playLine.X1 = playLine.X2 = X(playhead); }
382
519
  private void UpdateTransport()
@@ -387,29 +524,41 @@ namespace DrumBuddy
387
524
  private void Change(Func<bool> action, string message)
388
525
  {
389
526
  if (IsBusy) return; Stop(); var before = session.Copy();
527
+ var oldIds = selectedIds.ToArray(); bool oldMode = clipSelection; long oldFrom = from, oldTo = to; int oldTrack = track;
528
+ Action restore = delegate { session = before; selectedIds.Clear(); foreach (string id in oldIds) selectedIds.Add(id); clipSelection = oldMode; from = oldFrom; to = oldTo; track = oldTrack; UpdateSelectionText(); BuildHeaders(); DrawCanvas(); };
390
529
  try {
391
- if (!action()) { status("No audio section was changed. Select a clip or a time range first."); return; }
392
- session.Validate(); undo.Push(before); redo.Clear(); dirty = true; BuildHeaders(); DrawCanvas(); status(message);
530
+ if (!action()) { restore(); status("No audio section was changed. Select a clip or a time range first."); return; }
531
+ session.Validate(); undo.Push(before); redo.Clear(); dirty = true; RefreshClipBounds(); BuildHeaders(); DrawCanvas(); status(message);
393
532
  }
394
- catch (Exception ex) { session = before; BuildHeaders(); DrawCanvas(); status(ex.Message); }
533
+ catch (Exception ex) { restore(); status(ex.Message); }
395
534
  }
396
535
  private void Undo(bool forward)
397
536
  {
398
537
  if (IsBusy) return; var source = forward ? redo : undo; var target = forward ? undo : redo;
399
- if (source.Count == 0) return; Stop(); target.Push(session.Copy()); session = source.Pop(); dirty = true; BuildHeaders(); DrawCanvas();
538
+ if (source.Count == 0) return; Stop(); target.Push(session.Copy()); session = source.Pop(); dirty = true; RefreshClipBounds(); BuildHeaders(); DrawCanvas();
400
539
  }
401
- private void Split() { Change(() => TimelineEdits.Split(session, track, AllTracks, playhead), "Split at the playhead."); }
540
+ private void Split() { Change(() => TimelineEdits.Split(session, track, AllTracks, playhead, clipSelection ? selectedIds : null), "Split at the playhead."); }
402
541
  private void Copy(bool cut)
403
542
  {
404
- if (IsBusy || !ReadSelection() || to <= from) { status("Select a time range to copy or cut."); return; }
405
- clipboard = TimelineEdits.Copy(session, track, AllTracks, from, to);
543
+ RefreshClipBounds();
544
+ if (IsBusy || !ReadSelection() || to <= from) { status("Select a clip or time range to copy or cut."); return; }
545
+ bool all = clipSelection ? session.Regions.Where(r => selectedIds.Contains(r.Id)).Select(r => r.Track).Distinct().Count() > 1 : AllTracks;
546
+ clipboard = TimelineEdits.Copy(session, track, all, from, to, clipSelection ? selectedIds : null);
406
547
  if (clipboard.Regions.Count == 0) { status("There is no audio in this selection."); return; }
407
- if (cut) Change(() => TimelineEdits.Delete(session, track, AllTracks, from, to), "Selection cut. Paste inserts it at the playhead.");
548
+ if (cut) Change(() => TimelineEdits.Delete(session, track, AllTracks, from, to, clipSelection ? selectedIds : null), "Selection cut. Paste inserts it at the playhead.");
408
549
  else status("Copied " + clipboard.Regions.Count + " audio section(s). Click the ruler for the paste position.");
409
550
  }
410
551
  private void DeleteRange()
411
- { if (ReadSelection() && to > from) Change(() => TimelineEdits.Delete(session, track, AllTracks, from, to), "Selected audio removed; the timeline gap is kept."); }
412
- private void Paste() { Change(() => TimelineEdits.Paste(session, clipboard, track, playhead), "Audio pasted at the playhead."); }
552
+ { if (ReadSelection() && to > from) Change(() => TimelineEdits.Delete(session, track, AllTracks, from, to, clipSelection ? selectedIds : null), "Selected audio removed; the timeline gap is kept."); }
553
+ private void Paste()
554
+ {
555
+ Change(delegate {
556
+ var before = new HashSet<string>(session.Regions.Select(r => r.Id));
557
+ if (!TimelineEdits.Paste(session, clipboard, track, playhead)) return false;
558
+ selectedIds.Clear(); foreach (var region in session.Regions.Where(r => !before.Contains(r.Id))) selectedIds.Add(region.Id);
559
+ clipSelection = true; return true;
560
+ }, "Audio pasted at the playhead.");
561
+ }
413
562
 
414
563
  private async Task ChooseAudio()
415
564
  {
@@ -442,7 +591,7 @@ namespace DrumBuddy
442
591
  }
443
592
  catch (OperationCanceledException) { status("Import cancelled. Your existing timeline is kept."); }
444
593
  catch (Exception ex) { status("Could not add audio: " + ex.Message); }
445
- finally { EndWork(); BuildHeaders(); DrawCanvas(); }
594
+ finally { EndWork(); BuildHeaders(); DrawCanvas(); timeline.Focus(); }
446
595
  }
447
596
  private void BeginWork()
448
597
  { operation = new CancellationTokenSource(); foreach (var button in fileButtons) button.IsEnabled = false; play.IsEnabled = false; cancel.Visibility = Visibility.Visible; RefreshGridToolbar(); }
@@ -450,7 +599,7 @@ namespace DrumBuddy
450
599
  { operation.Dispose(); operation = null; foreach (var button in fileButtons) button.IsEnabled = true; play.IsEnabled = true; cancel.Visibility = Visibility.Collapsed; RefreshGridToolbar(); }
451
600
  private void NewSession()
452
601
  {
453
- if (IsBusy || !ConfirmClose()) return; Stop(); session = new EditorSession { Grid = InterfacePreferences.Current.Grid.Copy() }; sessionPath = null; assets.Clear(); undo.Clear(); redo.Clear(); dirty = false; from = to = playhead = 0; UpdateSelectionText(); BuildHeaders(); DrawCanvas();
602
+ if (IsBusy || !ConfirmClose()) return; Stop(); session = new EditorSession { Grid = InterfacePreferences.Current.Grid.Copy() }; sessionPath = null; assets.Clear(); undo.Clear(); redo.Clear(); dirty = false; selectedIds.Clear(); clipSelection = false; from = to = playhead = 0; UpdateSelectionText(); BuildHeaders(); DrawCanvas();
454
603
  }
455
604
  public bool SaveSession(bool saveAs)
456
605
  {
@@ -479,7 +628,7 @@ namespace DrumBuddy
479
628
  }
480
629
  using (var verify = new TimelineMix(loaded, decoded)) { }
481
630
  operation.Token.ThrowIfCancellationRequested(); session = loaded; assets.Clear(); foreach (var pair in decoded) assets.Add(pair.Key, pair.Value);
482
- sessionPath = dialog.FileName; undo.Clear(); redo.Clear(); dirty = false; from = to = playhead = 0; UpdateSelectionText(); status("Editor session opened.");
631
+ sessionPath = dialog.FileName; undo.Clear(); redo.Clear(); dirty = false; selectedIds.Clear(); clipSelection = false; from = to = playhead = 0; UpdateSelectionText(); status("Editor session opened.");
483
632
  }
484
633
  catch (OperationCanceledException) { status("Opening cancelled. Your current timeline is kept."); }
485
634
  catch (Exception ex) { status("Could not open this session: " + ex.Message); }
@@ -563,12 +712,15 @@ namespace DrumBuddy
563
712
  }
564
713
  public async Task<bool> ExecuteAction(string action)
565
714
  {
566
- if (IsBusy && action != "stop") return false;
715
+ if (action == "selectTool" && clipDrag != ClipDrag.None) CancelClipDrag();
716
+ if (IsBusy && action != "stop" && !action.StartsWith("zoom", StringComparison.Ordinal)) return false;
717
+ if (ExecuteClipAction(action)) return true;
567
718
  switch (action) {
568
719
  case "play": PlayPause(); break;
569
720
  case "stop": Stop(); UpdateTransport(); break;
570
721
  case "split": Split(); break;
571
- case "scissors": scissorsTool.IsChecked = scissorsTool.IsChecked != true; break;
722
+ case "selectTool": SetPointerTool("select"); break;
723
+ case "scissors": sliceCursor = !sliceCursor; timeline.Cursor = sliceCursor ? Cursors.Cross : Cursors.Arrow; break;
572
724
  case "copy": Copy(false); break;
573
725
  case "cut": Copy(true); break;
574
726
  case "paste": Paste(); break;
@@ -583,14 +735,19 @@ namespace DrumBuddy
583
735
  case "export": await ExportMix(); break;
584
736
  case "start": Stop(); playhead = 0; DrawCanvas(); EnsurePlayheadVisible(); break;
585
737
  case "end": Stop(); playhead = session.End; DrawCanvas(); EnsurePlayheadVisible(); break;
738
+ case "zoomIn": case "zoomInPlus": case "zoomInKeypad": ZoomStep(2); break;
739
+ case "zoomOut": case "zoomOutKeypad": ZoomStep(.5); break;
740
+ case "zoomFit": FitZoom(false); break;
741
+ case "zoomSelection": FitZoom(true); break;
586
742
  default: return false;
587
743
  }
588
744
  return true;
589
745
  }
590
746
  public bool HandleKey(KeyEventArgs e)
591
747
  {
748
+ if (e.Key == Key.Escape && clipDrag != ClipDrag.None) { CancelClipDrag(); timeline.ReleaseMouseCapture(); return true; }
592
749
  if (Keyboard.FocusedElement is TextBox || Keyboard.FocusedElement is ComboBox) return false;
593
- if (e.Key == Key.Escape && gesture.Mode != TimelineDrag.None) { gesture.Cancel(); ApplyGesture(); timeline.ReleaseMouseCapture(); return true; }
750
+ if (e.Key == Key.Escape && gesture.Mode != TimelineDrag.None) { gesture.Cancel(); selectedIds.Clear(); if (previousIds != null) foreach (string id in previousIds) selectedIds.Add(id); clipSelection = previousClipSelection; track = previousTrack; ApplyGesture(); BuildHeaders(); DrawCanvas(); timeline.ReleaseMouseCapture(); return true; }
594
751
  string action = InterfacePreferences.Resolve(e.Key == Key.System ? e.SystemKey : e.Key, Keyboard.Modifiers);
595
752
  if (action == null || action == "record") return false;
596
753
  var pending = ExecuteAction(action);
@@ -605,11 +762,26 @@ namespace DrumBuddy
605
762
  return choice == MessageBoxResult.No || (choice == MessageBoxResult.Yes && SaveSession(false));
606
763
  }
607
764
  internal void SetForTest(EditorSession state, IDictionary<string, Clip> decoded)
608
- { session = state.Copy(); assets.Clear(); foreach (var p in decoded) assets.Add(p.Key, p.Value); undo.Clear(); redo.Clear(); dirty = false; from = to = playhead = 0; UpdateSelectionText(); BuildHeaders(); DrawCanvas(); }
765
+ { session = state.Copy(); assets.Clear(); foreach (var p in decoded) assets.Add(p.Key, p.Value); undo.Clear(); redo.Clear(); dirty = false; selectedIds.Clear(); clipSelection = false; from = to = playhead = 0; UpdateSelectionText(); BuildHeaders(); DrawCanvas(); }
609
766
  internal EditorSession SessionForTest { get { return session.Copy(); } }
610
- internal void ShowSelectionForTest(long start, long end) { from = start; to = end; UpdateSelectionText(); DrawCanvas(); }
767
+ internal void ListenVolumeForTest(double value) { volume.Value = value; }
768
+ internal bool PlayingForTest { get { return output.Playing; } }
769
+ internal void ShowSelectionForTest(long start, long end) { clipSelection = false; selectedIds.Clear(); from = start; to = end; UpdateSelectionText(); DrawCanvas(); }
611
770
  internal void PlayheadForTest(long value) { playhead = value; DrawCanvas(); }
612
- internal void ZoomForTest(double value) { pixelsPerSecond = value; DrawCanvas(); }
771
+ internal void ZoomForTest(double value) { SetZoom(value, 0); }
772
+ internal double ZoomPixelsForTest { get { return pixelsPerSecond; } }
773
+ internal double OffsetForTest { get { return scroll.HorizontalOffset; } }
774
+ internal double WidthForTest { get { return ViewportWidth; } }
775
+ internal int DetailsForTest { get { return timeline.Children.OfType<Polygon>().Count(p => (string)p.Tag == "WaveformDetail"); } }
776
+ internal void ZoomAtForTest(double value, double pointer) { SetZoom(value, pointer); }
777
+ internal void OffsetForTestSet(double value) { scroll.ScrollToHorizontalOffset(value); scroll.UpdateLayout(); DrawCanvas(); }
778
+ internal void PointerDownForTest(long frame, int targetTrack, bool additive) { BeginPointer(new Point(X(frame), RulerHeight + targetTrack * RowHeight + 20), additive, 1); }
779
+ internal void PointerUpForTest(long frame) { if (gesture.Mode != TimelineDrag.None) EndPointer(X(frame)); }
780
+ internal void PointerMoveForTest(long frame) { MovePointer(X(frame)); }
781
+ internal int SelectedClipsForTest { get { return clipSelection ? selectedIds.Count : 0; } }
782
+ internal int HighlightedForTest { get { return regionVisuals.Count(v => (string)v.Body.Tag == "SelectedRegion"); } }
783
+ internal bool SelectCursorForTest { get { return timeline.Cursor == Cursors.Arrow; } }
784
+ internal double TimelineHeightForTest { get { return scroll.ViewportHeight; } }
613
785
  internal bool CheckUndoRedoForTest()
614
786
  {
615
787
  var original = session.Copy();
@@ -620,7 +792,7 @@ namespace DrumBuddy
620
792
  return restored && repeated && dirty;
621
793
  }
622
794
  public void Dispose()
623
- { if (disposed) return; disposed = true; if (operation != null) operation.Cancel(); timer.Stop(); Stop(); output.Dispose(); }
795
+ { if (disposed) return; disposed = true; waveformDetail.Dispose(); if (operation != null) operation.Cancel(); timer.Stop(); Stop(); output.Dispose(); }
624
796
  public void CancelWork() { if (operation != null) operation.Cancel(); }
625
797
  }
626
798
  }
Binary file