Skip to main content

max / audiofiles

Flip the sample editor to the described screen The editor serves from `quasi::edit` and `ui/edit_panel.rs` is gone. Every act matched exactly on the first run. The whole of the diff was fields, which is what this screen mostly is: the shipped controls carry no label, so each is announced by its own value, and a slider drawn with `show_value(false)` is announced as nothing at all. `Parity::unnamed_fields` states both sides in one call rather than sixteen, and comes out when makeover-immediate 0.34.0 is published. One real rename found on the way: the shipped normalize radio says "LUFS" and the described one says what LUFS measures. The harness was under-modelling a radio field. It emitted one offer per `Field`, and every renderer draws a `FieldKind::Radio` as one control per option, so seven of the editor's controls looked like a difference and were the reader's. `Select` keeps one offer: a box you open is not a set of controls, and its options are not on screen until it is.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 18:07 UTC
Signed with PGP, not checked
Commit: 73dcf75166c722b88d130cb5bb33a5d44afee9af
Parent: 4de115c
5 files changed, +88 insertions, -522 deletions
@@ -4,8 +4,8 @@
4 4
5 5 use crate::state::{BrowserState, ImportMode};
6 6 use crate::ui::{
7 - detail, edit_panel, export_screens, file_list, filter_panel, footer, import_screens,
8 - instrument_panel, layout_strip, overlays, sidebar, theme, toolbar,
7 + detail, export_screens, file_list, filter_panel, footer, import_screens, instrument_panel,
8 + layout_strip, overlays, sidebar, theme, toolbar,
9 9 };
10 10 use audiofiles_core::vfs::NodeType;
11 11
@@ -270,14 +270,6 @@
270 270 }
271 271
272 272 // Floating sample editor window
273 - if state.edit.show_window {
274 - edit_panel::draw_edit_window(ctx, state);
275 - }
276 -
277 - // The described editor, beside the shipped one and on the same condition:
278 - // it is a window the app opens for a sample rather than one with a toggle,
279 - // so there is no second flag to keep in step.
280 - #[cfg(feature = "quasi")]
281 273 if state.edit.show_window {
282 274 crate::quasi::panel::draw_edit(ctx, state);
283 275 }
@@ -288,14 +288,7 @@
288 288 themes: themes(),
289 289 intents: &intents,
290 290 };
291 - let closed = window(
292 - ctx,
293 - "Sample Editor (described)",
294 - &mut runtime,
295 - &host,
296 - "/edit",
297 - true,
298 - );
291 + let closed = window(ctx, "Sample Editor", &mut runtime, &host, "/edit", true);
299 292 state.described.edit = runtime;
300 293 apply(ctx, state, None, intents.into_inner());
301 294 if closed {
@@ -197,6 +197,23 @@
197 197 out
198 198 }
199 199
200 + /// What one field offers, which is not always one control.
201 + ///
202 + /// A `Radio` is drawn as one control per option by every renderer, so it offers
203 + /// as many things as it has options and each is named by its own label. Every
204 + /// other kind is a single control named by the field's question -- including
205 + /// `Select`, which is a box you open rather than a set of controls, so its
206 + /// options are not on screen until you do.
207 + fn field_offers(field: &quasi_router::Field, out: &mut Offering) {
208 + if field.kind == layout::FieldKind::Radio {
209 + for choice in &field.options {
210 + out.push(Role::Choice, choice.label.clone(), false);
211 + }
212 + return;
213 + }
214 + out.push(field_role(field.kind), field.label.clone(), false);
215 + }
216 +
200 217 /// The role a field of this kind is drawn as.
201 218 fn field_role(kind: layout::FieldKind) -> Role {
202 219 use layout::FieldKind as K;
@@ -226,14 +243,14 @@
226 243 // fields, and those are as much of what the screen offers as a
227 244 // field standing on its own.
228 245 for field in &act.asks {
229 - out.push(field_role(field.kind), field.label.clone(), false);
246 + field_offers(field, out);
230 247 }
231 248 }
232 249 Node::Link { text, action } => {
233 250 out.push(Role::Button, text.clone(), false);
234 251 out.addresses.push(action.destination.as_str().to_owned());
235 252 }
236 - Node::Field(field) => out.push(field_role(field.kind), field.label.clone(), false),
253 + Node::Field(field) => field_offers(field, out),
237 254 Node::Form {
238 255 submit,
239 256 action,
@@ -242,7 +259,7 @@
242 259 out.push(Role::Button, submit.clone(), false);
243 260 out.addresses.push(action.destination.as_str().to_owned());
244 261 for field in fields {
245 - out.push(field_role(field.kind), field.label.clone(), false);
262 + field_offers(field, out);
246 263 }
247 264 }
248 265 Node::Select {
@@ -573,6 +590,28 @@
573 590 self.gaining(label)
574 591 }
575 592
593 + /// Every field on a screen the renderer draws unnamed, both sides at once.
594 + ///
595 + /// [`unnamed_field`](Self::unnamed_field)'s bulk form, for the screens where
596 + /// the gap is most of the diff. `asked` is what the description calls each
597 + /// question; `shown` is what the unnamed control is announced as instead,
598 + /// which is its value where it has one and nothing at all where it does not
599 + /// -- a slider drawn with `show_value(false)` says neither.
600 + ///
601 + /// Two lists rather than pairs, because they do not pair one to one: a
602 + /// field whose control shows nothing contributes to `asked` and not to
603 + /// `shown`. One call is still one claim, which is the point.
604 + #[must_use]
605 + pub(super) fn unnamed_fields(mut self, asked: &[&str], shown: &[&str]) -> Self {
606 + for label in asked {
607 + self = self.gaining(label);
608 + }
609 + for value in shown {
610 + self = self.dropping(value);
611 + }
612 + self
613 + }
614 +
576 615 /// Assert the two sides offer the same thing, panicking with a diff if not.
577 616 pub(super) fn assert(&self, described: &Offering, shipped: &Offering) {
578 617 let mut want: BTreeMap<Offer, isize> = BTreeMap::new();
@@ -961,3 +1000,46 @@
961 1000 .dropping("Select device...")
962 1001 .assert(&described, &drawn);
963 1002 }
1003 +
1004 + /// A sample open in the editor, which is what that screen is about.
1005 + fn with_the_editor_open(state: &mut crate::state::BrowserState) {
1006 + state.nav.selection.set_single(0);
1007 + state.open_edit_window("aaa111");
1008 + }
1009 +
1010 + #[test]
1011 + fn the_editor_serves_what_it_describes() {
1012 + let (mut state, _dir) = fixture();
1013 + with_the_editor_open(&mut state);
1014 +
1015 + let described = described(&super::panel::described_screen(&state, "/edit"));
1016 + let drawn = shipped(|ui| {
1017 + super::panel::draw_edit(ui.ctx(), &mut state);
1018 + });
1019 +
1020 + described.addresses_resolve();
1021 + Parity::strict()
1022 + .in_a_window("Sample Editor")
1023 + // The unnamed-field gap, on the screen that has the most of it. What
1024 + // the renderer announces is each control's own value, so the right-hand
1025 + // list is this fixture's numbers rather than anything either side says.
1026 + // Both lists come out when makeover-immediate 0.34.0 is published.
1027 + .unnamed_fields(
1028 + &[
1029 + "Start",
1030 + "End",
1031 + "Gain",
1032 + "Target",
1033 + "Duration",
1034 + "Length",
1035 + "Insert at",
1036 + "Remove from",
1037 + "to",
1038 + "Curve",
1039 + ],
1040 + &[
1041 + "0", "0", "0", "100", "-1.0", "0.0", "0.000", "1.000", "100", "Linear",
1042 + ],
1043 + )
1044 + .assert(&described, &drawn);
1045 + }
@@ -4,7 +4,6 @@
4 4 pub mod color;
5 5 pub mod detail;
6 6 pub mod dialog;
7 - pub mod edit_panel;
8 7 pub mod export_screens;
9 8 pub mod file_list;
10 9 pub mod file_list_menus;
@@ -1,760 +1,0 @@
1 - //! Floating sample editor window: all edit controls visible simultaneously.
2 -
3 - use egui;
4 -
5 - use crate::state::{BrowserState, EditResultMode};
6 - use crate::waveform;
7 - use audiofiles_core::edit::FadeCurve;
8 -
9 - use super::theme;
10 - use super::widgets;
11 -
12 - /// Draw the floating sample editor window. Call from the overlay layer.
13 - pub fn draw_edit_window(ctx: &egui::Context, state: &mut BrowserState) {
14 - let mut open = state.edit.show_window;
15 - widgets::tool_window(ctx, "Sample Editor", &mut open, 400.0, 320.0, |ui| {
16 - // In-progress bar. Drawn at the top while an edit applies; the body
17 - // below stays rendered (every section greys itself out via its own
18 - // `in_progress` disabled flag) so the user keeps the waveform and
19 - // controls for spatial reference instead of the panel collapsing to a
20 - // lone spinner.
21 - if state.edit.in_progress {
22 - ui.horizontal(|ui| {
23 - ui.spinner();
24 - ui.label("Applying edit...");
25 - // M-11: best-effort cancel. Signals the worker and clears
26 - // in_progress so the UI is interactive even if the worker
27 - // is mid-write, the cancel is advisory, not synchronous.
28 - if ui.button("Cancel").clicked() {
29 - state.cancel_edit_operation();
30 - }
31 - });
32 - ui.separator();
33 - }
34 -
35 - // Result prompt overlay
36 - if state.edit.result_prompt {
37 - draw_result_prompt(ui, state);
38 - return;
39 - }
40 -
41 - let hash = match &state.edit.hash {
42 - Some(h) => h.clone(),
43 - None => return,
44 - };
45 -
46 - draw_waveform_section(ui, state, &hash);
47 - draw_info_line(ui, state);
48 - draw_transport_section(ui, state, &hash);
49 -
50 - ui.separator();
51 - draw_trim_section(ui, state);
52 -
53 - ui.separator();
54 - draw_levels_section(ui, state);
55 -
56 - ui.separator();
57 - draw_transform_section(ui, state);
58 -
59 - ui.separator();
60 - draw_silence_section(ui, state);
61 -
62 - ui.separator();
63 - draw_result_section(ui, state);
64 -
65 - // Batch edit section (shown when multiple samples selected)
66 - ui.separator();
67 - draw_batch_section(ui, state);
68 - });
69 - state.edit.show_window = open;
70 - }
71 -
72 - /// Waveform display with playback cursor and click-to-seek.
73 - fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) {
74 - if let Some(ref waveform_data) = state.detail.selected_waveform {
75 - // Show the playhead whenever this sample is the active preview and a
76 - // buffer is loaded, including while paused, so the user can see where
77 - // playback sits before auditioning a trim.
78 - let playback_pos = if state.preview.previewing_hash.as_deref() == Some(hash) {
79 - let playback = state.shared.preview.lock();
80 - playback.buffer.as_ref().and_then(|buf| {
81 - let total_frames = if playback.streaming {
82 - playback
83 - .total_frames_estimate
84 - .unwrap_or(playback.decoded_frames)
85 - } else {
86 - buf.data.len() / 2
87 - };
88 - (total_frames > 0).then(|| (playback.position_frac / total_frames as f64) as f32)
89 - })
90 - } else {
91 - None
92 - };
93 -
94 - // p-6: 120px matches the detail panel waveform; the edit context wants
95 - // the larger surface for precise trim work (was 80px).
96 - let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 120.0);
97 -
98 - // C-1 part 1: paint the trim preview overlay. Regions outside the
99 - // current [trim_start, trim_end] are dimmed so the user sees what
100 - // *will be removed* before clicking Trim. Slider edges = preview
101 - // edges. Updates live as the user drags.
102 - let trim_start = state.edit.trim_start;
103 - let trim_end = state.edit.trim_end;
104 - if trim_start > 0.001 || trim_end < 0.999 {
105 - let rect = resp.rect;
106 - let painter = ui.painter_at(rect);
107 - let overlay = theme::trim_mute_overlay();
108 - if trim_start > 0.0 {
109 - let x_end = rect.left() + rect.width() * trim_start;
110 - painter.rect_filled(
111 - egui::Rect::from_min_max(rect.min, egui::pos2(x_end, rect.max.y)),
112 - 0.0,
113 - overlay,
114 - );
115 - }
116 - if trim_end < 1.0 {
117 - let x_start = rect.left() + rect.width() * trim_end;
118 - painter.rect_filled(
119 - egui::Rect::from_min_max(egui::pos2(x_start, rect.min.y), rect.max),
120 - 0.0,
121 - overlay,
122 - );
123 - }
124 - }
125 -
126 - // Draggable trim handles drawn over the waveform. These are the primary
127 - // way to set the cut region; the Start/End sliders below are the numeric
128 - // path. Handles are always present (even at the 0.0/1.0 default) so the
129 - // user can grab either edge directly.
130 - {
131 - let rect = resp.rect;
132 - let (new_start, start_dragged) =
133 - trim_handle(ui, rect, state.edit.trim_start, "edit_trim_handle_start");
134 - let (new_end, end_dragged) =
135 - trim_handle(ui, rect, state.edit.trim_end, "edit_trim_handle_end");
136 - // Only the handle the user is actually dragging moves; it clamps
137 - // against the other so the untouched handle never jumps (and a
138 - // minimum region is preserved).
139 - const MIN_GAP: f32 = 0.001;
140 - if start_dragged {
141 - state.edit.trim_start = new_start.clamp(0.0, state.edit.trim_end - MIN_GAP);
142 - }
143 - if end_dragged {
144 - state.edit.trim_end = new_end.clamp(state.edit.trim_start + MIN_GAP, 1.0);
145 - }
146 - }
147 -
148 - // Click-to-seek. If this sample isn't the active preview yet, start it
149 - // first so a click in the editor auditions from the clicked point,
150 - // previously the click was a silent no-op until preview was started
151 - // elsewhere.
152 - if resp.clicked()
153 - && let Some(pos) = resp.interact_pointer_pos()
154 - {
155 - let rect = resp.rect;
156 - let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
157 - if state.preview.previewing_hash.as_deref() != Some(hash) {
158 - state.trigger_preview(hash);
159 - }
160 - let mut playback = state.shared.preview.lock();
161 - if let Some(ref buf) = playback.buffer {
162 - let total_frames = if playback.streaming {
163 - playback
164 - .total_frames_estimate
165 - .unwrap_or(playback.decoded_frames)
166 - } else {
167 - buf.data.len() / 2
168 - };
169 - playback.position_frac = (normalized as f64 * total_frames as f64)
170 - .min((playback.decoded_frames.max(1) - 1) as f64);
171 - }
172 - }
173 - }
174 - }
175 -
176 - /// Transport row: Play/Pause/Stop for the sample being edited, independent of
177 - /// the main file-list selection so the user can audition before committing a
178 - /// destructive edit.
179 - fn draw_transport_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) {
180 - let is_current = state.preview.previewing_hash.as_deref() == Some(hash);
181 - let playing = is_current && state.shared.preview.lock().playing;
182 - ui.horizontal(|ui| {
183 - if widgets::secondary_button(ui, if playing { "Pause" } else { "Play" }).clicked() {
184 - if is_current {
185 - // Toggle play/pause on the already-loaded buffer.
186 - let mut pb = state.shared.preview.lock();
187 - pb.playing = !pb.playing;
188 - } else {
189 - state.trigger_preview(hash);
190 - }
191 - }
192 - if widgets::secondary_button(ui, "Stop").clicked() {
193 - state.stop_preview();
194 - }
195 - });
196 - }
197 -
198 - /// Draw a draggable trim-boundary handle over the waveform at `frac` (0..1).
199 - /// Returns `(new_frac, dragged)`. The hit area is wider than the painted line
200 - /// (Fitts) so it is easy to grab, the cursor switches to a horizontal resize on
201 - /// hover, and the line thickens while hovered or dragged for tactile feedback.
202 - fn trim_handle(ui: &mut egui::Ui, rect: egui::Rect, frac: f32, id_salt: &str) -> (f32, bool) {
203 - let frac = frac.clamp(0.0, 1.0);
204 - let x = rect.left() + rect.width() * frac;
205 - let hit = egui::Rect::from_min_max(
206 - egui::pos2(x - 4.0, rect.top()),
207 - egui::pos2(x + 4.0, rect.bottom()),
208 - );
209 - let resp = ui.interact(hit, ui.id().with(id_salt), egui::Sense::drag());
210 - let active = resp.hovered() || resp.dragged();
211 - if active {
212 - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
213 - }
214 - let mut new_frac = frac;
215 - if resp.dragged()
216 - && let Some(p) = resp.interact_pointer_pos()
217 - {
218 - new_frac = ((p.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
219 - }
220 - let width = if active { 2.5 } else { 1.0 };
221 - ui.painter_at(rect).line_segment(
222 - [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
223 - egui::Stroke::new(width, theme::warning()),
224 - );
225 - (new_frac, resp.dragged())
226 - }
227 -
228 - /// Info line: name, sample rate, duration, peak dB.
229 - fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) {
230 - if let Some(ref analysis) = state.detail.selected_analysis {
231 - let name = state
232 - .selected_node()
233 - .map(|n| n.node.name.clone())
234 - .unwrap_or_default();
235 - let duration = analysis.duration;
236 - let sr = analysis.sample_rate;
237 - // Only append the peak segment (and its separator) when a value exists,
238 - // so a missing peak_db doesn't leave a dangling " \u{00B7} ".
239 - let peak_suffix = analysis
240 - .peak_db
241 - .map(|p| format!(" \u{00B7} {p:.1} dBFS"))
242 - .unwrap_or_default();
243 -
244 - ui.horizontal_wrapped(|ui| {
245 - ui.label(egui::RichText::new(&name).strong().size(12.0));
246 - ui.label(
247 - egui::RichText::new(format!("{sr} Hz \u{00B7} {duration:.3}s{peak_suffix}"))
248 - .color(theme::content_muted())
249 - .size(11.0),
250 - );
251 - });
252 - ui.add_space(theme::space::hair());
253 - }
254 - }
255 -
256 - /// Trim section with start/end sliders.
257 - fn draw_trim_section(ui: &mut egui::Ui, state: &mut BrowserState) {
258 - let disabled = state.edit.in_progress || state.edit.hash.is_none();
259 -
260 - ui.label(egui::RichText::new("Trim").strong());
261 -
262 - let sample_rate = state
263 - .detail
264 - .selected_analysis
265 - .as_ref()
266 - .map_or(44100, |a| a.sample_rate);
267 - let total = state.edit.total_frames;
268 - let start_time = state.edit.trim_start as f64 * total as f64 / sample_rate as f64;
269 - let end_time = state.edit.trim_end as f64 * total as f64 / sample_rate as f64;
270 -
271 - ui.horizontal(|ui| {
272 - ui.label("Start:");
273 - ui.add_enabled(
274 - !disabled,
275 - egui::Slider::new(&mut state.edit.trim_start, 0.0..=1.0).show_value(false),
276 - );
277 - ui.label(egui::RichText::new(format!("{start_time:.3}s")).color(theme::content_muted()));
278 - });
279 -
280 - ui.horizontal(|ui| {
281 - ui.label("End:");
282 - ui.add_enabled(
283 - !disabled,
284 - egui::Slider::new(&mut state.edit.trim_end, 0.0..=1.0).show_value(false),
285 - );
286 - ui.label(egui::RichText::new(format!("{end_time:.3}s")).color(theme::content_muted()));
287 - });
288 -
289 - // Clamp start < end
290 - if state.edit.trim_start >= state.edit.trim_end {
291 - state.edit.trim_start = (state.edit.trim_end - 0.001).max(0.0);
292 - }
293 -
294 - ui.horizontal(|ui| {
295 - if ui
296 - .add_enabled(!disabled, egui::Button::new("Trim"))
297 - .clicked()
298 - {
299 - state.apply_edit_trim();
300 - }
301 - });
302 - }
303 -
304 - /// Levels section: gain and normalize.
305 - fn draw_levels_section(ui: &mut egui::Ui, state: &mut BrowserState) {
306 - let disabled = state.edit.in_progress || state.edit.hash.is_none();
307 -
308 - ui.label(egui::RichText::new("Levels").strong());
309 -
310 - // Current peak display + gain clipping warning
311 - let current_peak = state
312 - .detail
313 - .selected_analysis
314 - .as_ref()
315 - .and_then(|a| a.peak_db);
316 - if let Some(peak) = current_peak {
317 - let predicted = peak + state.edit.gain_db;
318 - if predicted > 0.0 {
319 - ui.colored_label(
320 - theme::danger(),
321 - format!("Peak: {peak:.1} dB \u{2192} {predicted:.1} dB (clips!)"),
322 - );
323 - }
324 - }
325 -
326 - // Gain
327 - ui.horizontal(|ui| {
328 - ui.label("Gain:");
329 - ui.add_enabled(
330 - !disabled,
331 - egui::Slider::new(&mut state.edit.gain_db, -24.0..=24.0).suffix(" dB"),
332 - );
333 - if ui
334 - .add_enabled(!disabled, egui::Button::new("Apply gain"))
335 - .clicked()
336 - {
337 - state.apply_edit_gain();
338 - }
339 - });
340 -
341 - // Normalize
342 - ui.horizontal(|ui| {
343 - ui.label("Normalize:");
344 - // M-13: switching mode resets the target to the canonical default for
345 - // the new mode (Peak: -1.0 dBFS, LUFS: -14.0 LUFS). The carried-over
346 - // value is meaningless across modes, so snap to a sane starting point.
347 - if ui
348 - .add_enabled(
349 - !disabled,
350 - egui::RadioButton::new(state.edit.norm_peak, "Peak"),
351 - )
352 - .clicked()
353 - {
354 - if !state.edit.norm_peak {
355 - state.edit.norm_target = -1.0;
356 - }
357 - state.edit.norm_peak = true;
358 - }
359 - if ui
360 - .add_enabled(
361 - !disabled,
362 - egui::RadioButton::new(!state.edit.norm_peak, "LUFS"),
363 - )
364 - .clicked()
365 - {
366 - if state.edit.norm_peak {
367 - state.edit.norm_target = -14.0;
368 - }
369 - state.edit.norm_peak = false;
370 - }
371 - });
372 -
373 - let norm_range = if state.edit.norm_peak {
374 - -24.0..=0.0
375 - } else {
376 - -24.0..=-6.0
377 - };
378 - let norm_suffix = if state.edit.norm_peak {
379 - " dBFS"
380 - } else {
381 - " LUFS"
382 - };
383 -
384 - ui.horizontal(|ui| {
385 - ui.add_enabled(
386 - !disabled,
387 - egui::Slider::new(&mut state.edit.norm_target, norm_range).suffix(norm_suffix),
388 - );
389 - if ui
390 - .add_enabled(!disabled, egui::Button::new("Normalize"))
391 - .clicked()
392 - {
393 - state.apply_edit_normalize();
394 - }
395 - });
396 - }
397 -
398 - /// Transform section: reverse and fade.
399 - fn draw_transform_section(ui: &mut egui::Ui, state: &mut BrowserState) {
400 - let disabled = state.edit.in_progress || state.edit.hash.is_none();
401 -
402 - ui.label(egui::RichText::new("Transform").strong());
403 -
404 - // Reverse
405 - if ui
406 - .add_enabled(!disabled, egui::Button::new("Reverse"))
407 - .clicked()
408 - {
409 - state.apply_edit_reverse();
410 - }
411 -
412 - // Fade
413 - ui.horizontal(|ui| {
414 - ui.label("Fade:");
415 - if ui
416 - .add_enabled(!disabled, egui::RadioButton::new(state.edit.fade_in, "In"))
417 - .clicked()
418 - {
419 - state.edit.fade_in = true;
420 - }
421 - if ui
422 - .add_enabled(
423 - !disabled,
424 - egui::RadioButton::new(!state.edit.fade_in, "Out"),
425 - )
426 - .clicked()
427 - {
428 - state.edit.fade_in = false;
429 - }
430 - });
431 -
432 - ui.horizontal(|ui| {
433 - // m-8: cap raised from 2000 to 10000 ms. Long pads / textures can want
434 - // multi-second fades; the old 2-second ceiling was invisible until hit.
435 - ui.add_enabled(
436 - !disabled,
437 - egui::Slider::new(&mut state.edit.fade_duration_ms, 10.0..=10000.0).suffix(" ms"),
438 - )
439 - .on_hover_text("Maximum fade duration 10s");
440 - egui::ComboBox::from_id_salt("edit_fade_curve")
441 - .selected_text(match state.edit.fade_curve {
442 - FadeCurve::Linear => "Linear",
443 - FadeCurve::Logarithmic => "Log",
444 - FadeCurve::SCurve => "S-Curve",
445 - })
446 - .width(70.0)
447 - .show_ui(ui, |ui| {
448 - ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Linear, "Linear");
449 - ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Logarithmic, "Log");
450 - ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::SCurve, "S-Curve");
451 - });
452 - if ui
453 - .add_enabled(!disabled, egui::Button::new("Apply fade"))
454 - .clicked()
455 - {
456 - state.apply_edit_fade();
457 - }
458 - });
459 - }
460 -
461 - /// Silence section: insert or remove silence.
462 - fn draw_silence_section(ui: &mut egui::Ui, state: &mut BrowserState) {
463 - let disabled = state.edit.in_progress || state.edit.hash.is_none();
464 -
465 - ui.label(egui::RichText::new("Silence").strong());
466 -
467 - // m-9: clamp Insert/Remove positions to [0, sample_duration_ms] when the
468 - // sample's analysis duration is known. Falls back to the previous unbounded
469 - // range only if duration is missing (un-analyzed sample). Prevents the
470 - // silent-failure / undefined-behaviour case where positions exceed length.
471 - let duration_ms_cap = state
472 - .detail
473 - .selected_analysis
474 - .as_ref()
475 - .map_or(f64::MAX, |a| a.duration * 1000.0);
476 -
477 - // Insert silence
478 - ui.horizontal(|ui| {
479 - ui.label("Insert at:");
480 - ui.add_enabled(
481 - !disabled,
482 - egui::DragValue::new(&mut state.edit.silence_position_ms)
483 - .speed(10.0)
484 - .range(0.0..=duration_ms_cap)
485 - .suffix(" ms"),
486 - );
487 - ui.label("Duration:");
488 - ui.add_enabled(
489 - !disabled,
490 - egui::DragValue::new(&mut state.edit.silence_duration_ms)
491 - .speed(10.0)
492 - .range(1.0..=60000.0)
493 - .suffix(" ms"),
494 - );
495 - if ui
496 - .add_enabled(!disabled, egui::Button::new("Insert"))
497 - .clicked()
498 - {
499 - state.apply_edit_insert_silence();
500 - }
Lines truncated