Skip to content

Commit f27e9c7

Browse files
Tokclaude
andcommitted
amen: fix 1-slice display + bigger wheel/wave + stable layout + buttons
Round of amen panel polish: Slice wheel: - When slice_count == 1, paint a filled disc instead of a single "full wedge" triangle-fan (which degenerates — first and last vertex coincide — leaving a visible seam). - Size 96 → 144 px (1.5× as requested). Padding around it bumped to 6 / 8 px so it doesn't hug neighbors. Waveform: - Height 44 → 66 px (1.5×). - Always reserve the vertical slot now: when no sample is loaded, paint an empty placeholder rect of the same size. Prevents the whole lower half of the panel jittering when a WAV arrives. Slice/DIR/BPM label alignment: - Fixed 48 px label column via a local `lbl` helper so SLICES / DIR / BPM all start at the same x. Fixes the SLICES-is-longer misalign. Dropdown: - Switched from inline selectable_label + load-inside-click to a selectable_value pattern plus a post-show_ui "did path change?" check. The old pattern occasionally lost the click when the combobox rebuilt its popup on the same frame. - Combo width reservation bumped (150 px instead of 96) for the wider RANDOM / LOAD button labels. Button renames: - RND → RANDOM - LD → LOAD Knob row: - Glued to the bottom of the panel via an `add_space(available - knob_row_h)` above the three-glass-pane row. Keeps the lower edge aligned to the module bottom when the upper content leaves slack. 481 tests still passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e3369c4 commit f27e9c7

1 file changed

Lines changed: 99 additions & 57 deletions

File tree

src/ui/panels/amen.rs

Lines changed: 99 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -266,29 +266,41 @@ fn draw_slice_wheel(
266266
// Start at 12 o'clock, clockwise → angle0 = -π/2.
267267
let dir = if reverse { -1.0 } else { 1.0 };
268268

269-
// Wedge fills
270-
for i in 0..n {
271-
let a0 = -std::f32::consts::FRAC_PI_2 + (i as f32 / n as f32) * tau * dir;
272-
let a1 = -std::f32::consts::FRAC_PI_2 + ((i + 1) as f32 / n as f32) * tau * dir;
273-
let active = active_slice.map(|s| s as usize) == Some(i);
269+
if n == 1 {
270+
// Single slice = the whole sample. A triangle-fan "full wedge"
271+
// degenerates (first and last vertex coincide), which renders as
272+
// a visible seam. Paint a proper filled disc instead.
273+
let active = active_slice.is_some();
274274
let fill = if active {
275275
egui::Color32::from_gray(180)
276276
} else {
277277
egui::Color32::from_gray(40)
278278
};
279-
// Tessellate the wedge as a triangle fan around the center.
280-
let steps = 12;
281-
let mut pts = vec![center];
282-
for k in 0..=steps {
283-
let t = k as f32 / steps as f32;
284-
let a = a0 + (a1 - a0) * t;
285-
pts.push(center + egui::vec2(a.cos(), a.sin()) * r_outer);
279+
painter.circle_filled(center, r_outer, fill);
280+
} else {
281+
for i in 0..n {
282+
let a0 = -std::f32::consts::FRAC_PI_2 + (i as f32 / n as f32) * tau * dir;
283+
let a1 = -std::f32::consts::FRAC_PI_2 + ((i + 1) as f32 / n as f32) * tau * dir;
284+
let active = active_slice.map(|s| s as usize) == Some(i);
285+
let fill = if active {
286+
egui::Color32::from_gray(180)
287+
} else {
288+
egui::Color32::from_gray(40)
289+
};
290+
// Tessellate the wedge as a triangle fan around the center.
291+
let steps = 12;
292+
let mut pts = vec![center];
293+
for k in 0..=steps {
294+
let t = k as f32 / steps as f32;
295+
let a = a0 + (a1 - a0) * t;
296+
pts.push(center + egui::vec2(a.cos(), a.sin()) * r_outer);
297+
}
298+
painter.add(egui::Shape::convex_polygon(
299+
pts,
300+
fill,
301+
egui::Stroke::new(0.5, egui::Color32::from_gray(15)),
302+
));
286303
}
287-
painter.add(egui::Shape::convex_polygon(
288-
pts,
289-
fill,
290-
egui::Stroke::new(0.5, egui::Color32::from_gray(15)),
291-
));
292304
}
293305
// Inner hole
294306
painter.circle_filled(center, r_inner, egui::Color32::from_gray(12));
@@ -416,14 +428,19 @@ pub fn draw_amen(app: &mut ImpulseApp, ui: &mut egui::Ui) {
416428
// Leave room for RND + LD buttons (two small buttons ~48px total
417429
// plus item_spacing). Using a fixed cap keeps the picker from
418430
// pushing outside the 3-cell module width on narrow rack layouts.
431+
// Track the previous path so we can detect a dropdown
432+
// selection AFTER the combobox closes — the earlier
433+
// "selectable_label.clicked() → load inline" pattern
434+
// sometimes lost the click when the combobox rebuilt its
435+
// popup on the same frame. Assigning selectable_value
436+
// directly to `path` and syncing state once after show_ui
437+
// is the more reliable pattern.
438+
let path_before = path.clone();
419439
egui::ComboBox::from_id_source("amen_sample_picker")
420-
// Room for RND + LD + PLAY (3 small buttons).
421-
.width((ui.available_width() - 96.0).max(60.0))
440+
// Room for RANDOM + LOAD + PLAY (3 buttons, wider now).
441+
.width((ui.available_width() - 150.0).max(60.0))
422442
.selected_text(egui::RichText::new(current_name).monospace().size(8.0))
423443
.show_ui(ui, |ui| {
424-
// Cap the popup height so long sample-pack dirs don't
425-
// overflow the rack/panel bounds (the user reported
426-
// entries getting cut off). 200px ≈ 14 rows at 8pt.
427444
egui::ScrollArea::vertical()
428445
.max_height(200.0)
429446
.show(ui, |ui| {
@@ -434,22 +451,20 @@ pub fn draw_amen(app: &mut ImpulseApp, ui: &mut egui::Ui) {
434451
.and_then(|n| n.to_str())
435452
.unwrap_or(sp_str.as_str())
436453
.to_string();
437-
if ui
438-
.selectable_label(
439-
path == sp_str,
440-
egui::RichText::new(name).monospace().size(8.0),
441-
)
442-
.clicked()
443-
{
444-
path = sp_str;
445-
app.state.write().amen.path = path.clone();
446-
load_and_cache(app, &path);
447-
}
454+
ui.selectable_value(
455+
&mut path,
456+
sp_str,
457+
egui::RichText::new(name).monospace().size(8.0),
458+
);
448459
}
449460
});
450461
});
462+
if path != path_before {
463+
app.state.write().amen.path = path.clone();
464+
load_and_cache(app, &path);
465+
}
451466
if ui
452-
.small_button(egui::RichText::new("RND").monospace().size(7.0))
467+
.small_button(egui::RichText::new("RANDOM").monospace().size(7.0))
453468
.on_hover_text("Load a random sample from samples/amen/")
454469
.clicked()
455470
&& let Some(rand_path) = pick_random_sample()
@@ -459,7 +474,7 @@ pub fn draw_amen(app: &mut ImpulseApp, ui: &mut egui::Ui) {
459474
load_and_cache(app, &rand_path);
460475
}
461476
if ui
462-
.small_button(egui::RichText::new("LD").monospace().size(7.0))
477+
.small_button(egui::RichText::new("LOAD").monospace().size(7.0))
463478
.on_hover_text("Reload the selected sample from disk")
464479
.clicked()
465480
{
@@ -528,8 +543,13 @@ pub fn draw_amen(app: &mut ImpulseApp, ui: &mut egui::Ui) {
528543
load_and_cache(app, &path);
529544
}
530545
let active = current_amen_slice(app);
546+
// Reserve the waveform vertical slot whether or not a sample is
547+
// loaded — otherwise the whole lower half of the panel shifts up
548+
// when loading a WAV, which jitters the knob positions. The rect
549+
// just stays empty until a thumbnail is available.
550+
let wave_h = 66.0;
551+
let wave_w = ui.available_width().min(260.0);
531552
if !app.amen_wave_cache.1.is_empty() {
532-
let wave_w = ui.available_width().min(260.0);
533553
let positions_snapshot: Vec<f32> = app.state.read().amen.slice_positions.clone();
534554
draw_waveform(
535555
ui,
@@ -540,7 +560,22 @@ pub fn draw_amen(app: &mut ImpulseApp, ui: &mut egui::Ui) {
540560
&positions_snapshot,
541561
active,
542562
wave_w,
543-
44.0,
563+
wave_h,
564+
);
565+
} else {
566+
// Reserve the same vertical slot so the panel layout doesn't
567+
// shift on load. A thin dark rect gives a visual "no sample"
568+
// hint without drawing anything noisy.
569+
let (rect, _) = ui.allocate_exact_size(egui::vec2(wave_w, wave_h), egui::Sense::hover());
570+
ui.painter().rect_filled(
571+
rect,
572+
egui::Rounding::same(2.0),
573+
egui::Color32::from_gray(10),
574+
);
575+
ui.painter().rect_stroke(
576+
rect,
577+
egui::Rounding::same(2.0),
578+
egui::Stroke::new(0.5, egui::Color32::from_gray(30)),
544579
);
545580
}
546581
// AUTO detects transients and populates AmenState.slice_positions;
@@ -592,20 +627,28 @@ pub fn draw_amen(app: &mut ImpulseApp, ui: &mut egui::Ui) {
592627
// STRETCH). Packing all three into that column frees the lower half
593628
// of the panel for a single knob row.
594629
let host_bpm = app.state.read().sequencer.bpm;
630+
// Fixed label column so SLICES / DIR / BPM align even though the
631+
// words differ in length. "SLICES" is the longest at 6 chars.
632+
let label_col = 48.0_f32;
633+
let lbl = |ui: &mut egui::Ui, text: &str| {
634+
let (rect, _) = ui.allocate_exact_size(egui::vec2(label_col, 14.0), egui::Sense::hover());
635+
ui.painter().text(
636+
rect.left_center(),
637+
egui::Align2::LEFT_CENTER,
638+
text,
639+
egui::FontId::monospace(7.5),
640+
theme::SMOKE,
641+
);
642+
};
595643
ui.horizontal(|ui| {
596644
// Small padding around the wheel so it doesn't hug other widgets.
597-
ui.add_space(4.0);
598-
draw_slice_wheel(ui, slice_count, active, reverse, loop_mode, 96.0);
599645
ui.add_space(6.0);
646+
draw_slice_wheel(ui, slice_count, active, reverse, loop_mode, 144.0);
647+
ui.add_space(8.0);
600648
ui.vertical(|ui| {
601649
// SLICES selector
602650
ui.horizontal(|ui| {
603-
ui.label(
604-
egui::RichText::new("SLICES")
605-
.monospace()
606-
.size(7.5)
607-
.color(theme::SMOKE),
608-
);
651+
lbl(ui, "SLICES");
609652
for &n in &[1u8, 2, 4, 8, 16] {
610653
let col = if slice_count == n {
611654
theme::CHALK
@@ -632,12 +675,7 @@ pub fn draw_amen(app: &mut ImpulseApp, ui: &mut egui::Ui) {
632675
// DIR (REV/FWD) + LOOP — label added to the direction toggle
633676
// so the state reads cleanly without needing the hub arrow.
634677
ui.horizontal(|ui| {
635-
ui.label(
636-
egui::RichText::new("DIR")
637-
.monospace()
638-
.size(7.5)
639-
.color(theme::SMOKE),
640-
);
678+
lbl(ui, "DIR");
641679
if widgets::toggle_button(ui, if reverse { "REV" } else { "FWD" }, &mut reverse) {
642680
changed = true;
643681
}
@@ -655,12 +693,7 @@ pub fn draw_amen(app: &mut ImpulseApp, ui: &mut egui::Ui) {
655693
// sequencer.bpm, matching the "default synced" behavior the
656694
// user expects.
657695
ui.horizontal(|ui| {
658-
ui.label(
659-
egui::RichText::new("BPM")
660-
.monospace()
661-
.size(7.5)
662-
.color(theme::SMOKE),
663-
);
696+
lbl(ui, "BPM");
664697
let mut v = source_bpm;
665698
if ui
666699
.add(egui::DragValue::new(&mut v).range(40.0..=300.0).speed(0.5))
@@ -688,6 +721,15 @@ pub fn draw_amen(app: &mut ImpulseApp, ui: &mut egui::Ui) {
688721
});
689722
});
690723

724+
// Push the knob row down so it's glued to the bottom of the panel
725+
// regardless of whether the waveform / slice-wheel section ended up
726+
// with spare vertical room. Estimate the knob row's own height
727+
// (~40 px including the glass-pane padding) and eat the remaining
728+
// available height above it.
729+
let knob_row_h = 40.0_f32;
730+
let spacer = (ui.available_height() - knob_row_h).max(0.0);
731+
ui.add_space(spacer);
732+
691733
// ── Knob row — three glass-pane groups on one line ──────────────────────
692734
// LEVEL (vol/pitch) · REGION (start/end) · SHAPE (gate/stutter).
693735
// even_group_width evenly distributes the 3 panes across the panel's

0 commit comments

Comments
 (0)