Skip to content

Commit 4ae7293

Browse files
ophiocusclaude
andcommitted
v0.4.31 — Mix tab uses ctx-level panels (real fix for lane Z-order bug)
User caught the diagnosis: the BOX (Frame::group border) of a lane sat in its proper place, but the lane's CONTENT (name, chips, M/S/A/B/+Cor buttons, profile dropdown) rendered somewhere else (at the top of the screen, overlapping the global menu bar). Same row's pieces drawing at different y-coords = painter-layer / Z-order mismatch, not bad rect math. Root cause: egui's painter uses multiple layers (Foreground for widgets, Background for panel fills, Tooltip for popups, plus ScrollArea-internal sublayers). Nesting TopBottomPanel::show_inside or child_ui + set_clip_rect inside the app's global CentralPanel::show(ctx, ...) only constrains the immediate layer — ComboBox popups, ScrollArea viewports, tooltip rendering ALL bypass the child's clip_rect and use the outer ui's. So the Frame::group border (drawn directly in the immediate layer) sat where it should, while the ComboBox/ScrollArea content for that row ended up unbounded. Fix: the Mix tab now declares its three panels (mix_transport_panel, mix_console_panel, lanes CentralPanel) AT CTX LEVEL, as siblings of the app's global menu bar (top_bar) and status bar (bottom_bar), NOT nested inside the global CentralPanel. This is the egui- blessed pattern for a multi-pane workspace — egui's panel system composites these cleanly because they all draw to the same level, no painter-layer mismatch. New `mix::ctx_panels(app, ctx)` called from app.rs when Tab::Mix is active. The previous `mix::show(app, ui)` becomes a thin placeholder for the empty-project case. Removed TRANSPORT_BAR_H and render_clipped helper from v0.4.30 — both were ceremony for the failed child_ui approach. 75 tests passing, clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2c47654 commit 4ae7293

5 files changed

Lines changed: 117 additions & 130 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); thi
88

99
(Nothing yet — known issues all resolved as of v0.4.23.)
1010

11+
## [0.4.31] — 2026-05-13
12+
13+
### Fixed
14+
- **Lane content rendering at wrong Y coords — root cause finally found and fixed.** The bug was visible across v0.4.29 / v0.4.30: the first lane's name + chips + M/S/A/B/+Cor row would render at the top of the screen overlapping the global menu bar, while the `Frame::group` border for that lane was either missing or in its proper place below the transport bar. Different parts of the same row drawing in different y-coords pointed at a **painter-layer / Z-order** issue, not just bad rect maths.
15+
- The actual cause: egui's painter uses **multiple layers** (Foreground for widgets, Background for panel fills, Tooltip for popups, plus ScrollArea-internal sublayers). Nesting `TopBottomPanel::show_inside` / `child_ui` + `set_clip_rect` inside the app's global `CentralPanel::show(ctx, ...)` only constrains the *immediate* layer — `ComboBox` popups, `ScrollArea` viewports, and tooltip rendering bypass the child's clip_rect and use the OUTER `ui`'s. So the lane's `Frame::group` (drawn directly in the immediate layer) sat where it should, while the ComboBox / ScrollArea content for that row ended up unbounded.
16+
- **Fix:** the Mix tab now declares its three panels (`mix_transport_panel`, `mix_console_panel`, lanes `CentralPanel`) **at ctx level**, as siblings of the app's global menu bar (`top_bar`) and status bar (`bottom_bar`), rather than nested inside the global `CentralPanel`. This is the egui-blessed pattern for a multi-pane workspace — egui's panel system composites these cleanly because they all draw to the same level, with no painter-layer mismatch. New `mix::ctx_panels(app, ctx)` function called directly from `app.rs` when `Tab::Mix` is active.
17+
- `app.rs` gains a branch: for Mix tab with tracks (and not the Visualizer takeover), it calls `mix::ctx_panels(self, ctx)`; everything else continues to render inside the global `CentralPanel` via `mix::show(self, ui)` (now a thin placeholder for the empty-project case).
18+
- Removed `TRANSPORT_BAR_H` constant and the `render_clipped` helper from v0.4.30 — both were ceremony for the failed child_ui approach.
19+
1120
## [0.4.30] — 2026-05-13
1221

1322
### Fixed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tinybooth-sound-studio"
3-
version = "0.4.30"
3+
version = "0.4.31"
44
edition = "2021"
55
authors = ["ophiocus <csantanad@gmail.com>"]
66
description = "Channel recorder, visualizer, and TinyBooth project exporter"

src/app.rs

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,22 +1642,41 @@ impl eframe::App for TinyBoothApp {
16421642
return;
16431643
}
16441644

1645-
egui::CentralPanel::default().show(ctx, |ui| {
1646-
// Visualizer takes over the central panel when toggled on.
1647-
// The user closes via the ✖ button inside the canvas, the
1648-
// 🌀 menu icon, or by switching tabs (we keep the active
1649-
// tab so they return to where they were).
1650-
if self.show_visualizer {
1651-
ui::visualizer::show(self, ui);
1652-
return;
1653-
}
1654-
match self.tab {
1655-
Tab::Record => ui::record::show(self, ui),
1656-
Tab::Project => ui::project::show(self, ui),
1657-
Tab::Mix => ui::mix::show(self, ui),
1658-
Tab::Export => ui::export::show(self, ui),
1659-
}
1660-
});
1645+
// v0.4.31 — Mix tab is special. When active (and not in
1646+
// Visualizer mode, and the project has tracks), it declares
1647+
// its own three panels (transport / console / lanes) at ctx
1648+
// level directly, as siblings of the app's global menu and
1649+
// status bars. This is the egui-blessed pattern for a multi-
1650+
// pane workspace; nesting `TopBottomPanel::show_inside` or
1651+
// `child_ui` inside the global `CentralPanel::show(ctx, ...)`
1652+
// doesn't propagate clip rects to all painter layers
1653+
// (ComboBox popups, ScrollArea viewports, tooltip layer),
1654+
// which manifested as lanes / button text overlapping the
1655+
// global menu bar in v0.4.29 / v0.4.30.
1656+
let use_mix_ctx_panels = matches!(self.tab, Tab::Mix)
1657+
&& !self.show_visualizer
1658+
&& !self.project.tracks.is_empty();
1659+
1660+
if use_mix_ctx_panels {
1661+
ui::mix::ctx_panels(self, ctx);
1662+
} else {
1663+
egui::CentralPanel::default().show(ctx, |ui| {
1664+
// Visualizer takes over the central panel when toggled on.
1665+
// The user closes via the ✖ button inside the canvas, the
1666+
// 🌀 menu icon, or by switching tabs (we keep the active
1667+
// tab so they return to where they were).
1668+
if self.show_visualizer {
1669+
ui::visualizer::show(self, ui);
1670+
return;
1671+
}
1672+
match self.tab {
1673+
Tab::Record => ui::record::show(self, ui),
1674+
Tab::Project => ui::project::show(self, ui),
1675+
Tab::Mix => ui::mix::show(self, ui),
1676+
Tab::Export => ui::export::show(self, ui),
1677+
}
1678+
});
1679+
}
16611680

16621681
// Mix-tab transport runs continuously while playing — repaint so
16631682
// the playhead animates.

src/ui/mix.rs

Lines changed: 71 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,10 @@ const FADER_H_MAX: f32 = 200.0;
6060
/// strip occupying half their screen. 340 px ≈ spectrum panel
6161
/// (80) + strip natural height (~230) + a touch of margin.
6262
const CONSOLE_H_MAX: f32 = 340.0;
63-
/// Fixed height for the transport bar region (v0.4.30). The
64-
/// previous nested-panel layout let it claim its natural size, but
65-
/// when content overflowed (long status, error banner) it'd push
66-
/// everything below it. With a fixed height the lanes / console
67-
/// regions don't shift; the transport itself self-clips if its
68-
/// content is taller (rare — only the error banner can grow).
69-
const TRANSPORT_BAR_H: f32 = 56.0;
63+
// v0.4.31 — TRANSPORT_BAR_H removed. The ctx-level `TopBottomPanel`
64+
// now sizes the transport region from its own content's natural
65+
// height (egui's panel layout is dynamic; the lanes/console below
66+
// absorb any wobble through their own panel's available_height).
7067
const METER_W: f32 = 6.0;
7168
/// Cap on track-name characters before we ellipsise. Tuned so Latin-script
7269
/// names like "Backing Vocals" / "Electric Guitar" / "Synth / Lead" fit
@@ -78,130 +75,92 @@ const FONT_STRIP_NAME: f32 = 13.0;
7875
const FONT_STRIP_DB: f32 = 12.0;
7976
const FONT_MASTER_NAME: f32 = 14.0;
8077

81-
/// Mix-tab entry point.
78+
/// Mix-tab entry point — empty-project placeholder.
8279
///
83-
/// **Architecture (v0.4.30 — explicit clipped child_ui layout):**
84-
///
85-
/// The Mix tab is split into three vertically-stacked regions
86-
/// rendered as `child_ui`s with **explicitly-set `clip_rect`s**. Pre-
87-
/// v0.4.30 we used egui's nested `TopBottomPanel::show_inside` +
88-
/// `CentralPanel::show_inside`, but that combination misbehaves when
89-
/// the outer surface is itself an app-level `CentralPanel::show(ctx,
90-
/// ...)`: lane rows would render *above* their host area, visible as
91-
/// the first lane spilling up into the global menu bar.
92-
///
93-
/// The new layout is explicit and predictable:
94-
///
95-
/// ```text
96-
/// ┌─ transport_rect (natural height, ~50 px) ──────────────────┐
97-
/// │ transport bar + optional error banner │
98-
/// ├─ lanes_rect (fills the middle, clip_rect set) ──────────────┤
99-
/// │ lanes_view (vertical ScrollArea, only scrollable surface) │
100-
/// ├─ console_rect (exact_height = CONSOLE_H, clip_rect set) ────┤
101-
/// │ spectrum panel + strip cards (horizontal scroll only) │
102-
/// └──────────────────────────────────────────────────────────────┘
103-
/// ```
104-
///
105-
/// Each region:
106-
/// • Has its rect computed up-front from `ui.max_rect()`, not
107-
/// incrementally from the cursor — so a 1-px wobble in any
108-
/// surface's content can't shift the others by a px each frame.
109-
/// • Gets a child `Ui` with its `clip_rect` set to its own rect,
110-
/// hard-bounding all drawing. Content overflow is physically
111-
/// impossible.
112-
/// • Owns its own scroll-event hit-testing because the child Ui's
113-
/// interact_rect is its clip_rect — wheel events outside a
114-
/// scroll area's rect are ignored.
80+
/// The **populated** Mix tab uses `ctx_panels` instead (called
81+
/// directly from `app.rs` for `Tab::Mix`). This function only
82+
/// handles the "no tracks yet" case so the central panel has
83+
/// something to render.
11584
pub fn show(app: &mut TinyBoothApp, ui: &mut egui::Ui) {
116-
if app.project.tracks.is_empty() {
85+
if !app.project.tracks.is_empty() {
86+
// Shouldn't normally happen — app.rs routes populated Mix
87+
// tabs through `ctx_panels`. Defensive: render an empty
88+
// central area, leaving the user to switch tabs.
11789
ui.heading("Mix");
11890
ui.separator();
119-
ui.label("Record at least one track or import a Suno bundle to mix.");
91+
ui.label("(rendering Mix tab via ctx_panels)");
12092
return;
12193
}
94+
ui.heading("Mix");
95+
ui.separator();
96+
ui.label("Record at least one track or import a Suno bundle to mix.");
97+
let _ = app; // suppress unused-mut on the empty path
98+
}
12299

100+
/// Mix-tab entry point at **ctx level** — declares its own
101+
/// `TopBottomPanel::top` (transport), `TopBottomPanel::bottom`
102+
/// (console deck), and `CentralPanel` (lanes) directly on `ctx`,
103+
/// as siblings of the app's global menu + status bars. Called from
104+
/// `app.rs` instead of `mix::show` when the Mix tab is active and
105+
/// the project has tracks.
106+
///
107+
/// **Why ctx-level (v0.4.31):** Pre-v0.4.31 the Mix tab nested
108+
/// these three panels inside the app's global `CentralPanel::show`
109+
/// via `show_inside`, `allocate_ui_with_layout`, then explicit
110+
/// `child_ui` + `set_clip_rect`. Each approach had a different
111+
/// failure mode (lanes overlaying transport, content bleeding past
112+
/// clip rects, widget text rendering at the wrong y-coord while
113+
/// the surrounding `Frame::group` border was correctly placed).
114+
/// The root cause across all of them: egui's painter has multiple
115+
/// layers (Foreground for widgets, Background for panel fills,
116+
/// Tooltip for popups), and nested `child_ui` + `set_clip_rect`
117+
/// only constrains the layer the immediate child is drawing to —
118+
/// `ComboBox` popups, `ScrollArea` viewports, tooltips all bypass
119+
/// it. Ctx-level panels claim space at the proper layer and egui
120+
/// composites them cleanly. This is the egui-blessed pattern.
121+
pub fn ctx_panels(app: &mut TinyBoothApp, ctx: &egui::Context) {
123122
rebuild_player_if_needed(app);
124123
consume_autoplay_request(app);
125124
capture_automation(app);
126125

127-
// Total area available to the Mix tab — taken from the host
128-
// CentralPanel's max_rect, NOT from cursor-based available_height
129-
// which can wobble with sibling widgets.
130-
let outer = ui.max_rect();
131-
let outer_w = outer.width();
132-
let total_h = outer.height().max(200.0);
133-
134-
// Heights: transport claims its natural needs (fixed estimate);
135-
// console claims a fraction (clamped); lanes get whatever's left.
136-
let transport_h = TRANSPORT_BAR_H;
137-
let console_h =
138-
(total_h * app.mix_console_fraction.clamp(0.2, 0.7)).clamp(180.0, CONSOLE_H_MAX);
139-
let lanes_h = (total_h - transport_h - console_h).max(120.0);
140-
141-
let top_y = outer.min.y;
142-
let transport_rect = Rect::from_min_size(
143-
Pos2::new(outer.min.x, top_y),
144-
egui::vec2(outer_w, transport_h),
145-
);
146-
let lanes_rect = Rect::from_min_size(
147-
Pos2::new(outer.min.x, top_y + transport_h),
148-
egui::vec2(outer_w, lanes_h),
149-
);
150-
let console_rect = Rect::from_min_size(
151-
Pos2::new(outer.min.x, top_y + transport_h + lanes_h),
152-
egui::vec2(outer_w, console_h),
153-
);
126+
// Compute console height for this frame. The bottom panel
127+
// claims exactly this; the central panel takes whatever's left.
128+
let console_h = {
129+
let screen_h = ctx.screen_rect().height().max(200.0);
130+
(screen_h * app.mix_console_fraction.clamp(0.2, 0.7)).clamp(180.0, CONSOLE_H_MAX)
131+
};
154132

155-
// ── Region 1: transport ───────────────────────────────────────
156-
render_clipped(ui, transport_rect, "mix_transport", |ui| {
157-
transport_bar(app, ui);
158-
render_player_error_banner_if_present(app, ui);
159-
});
133+
// ── Top: transport ───────────────────────────────────────────
134+
egui::TopBottomPanel::top("mix_transport_panel")
135+
.resizable(false)
136+
.show(ctx, |ui| {
137+
transport_bar(app, ui);
138+
render_player_error_banner_if_present(app, ui);
139+
});
160140

161-
// Early-return path: error banner up, no player. Transport
162-
// already drew the Retry button above.
141+
// Error-banner early return — player isn't ready, skip the
142+
// bottom panel and the lanes. egui will render the rest of
143+
// the app (other panels) normally.
163144
if app.player.is_none() {
145+
// Render an empty central panel so the visualizer / other
146+
// surface state stays consistent. Otherwise egui complains
147+
// about a missing CentralPanel for this frame.
148+
egui::CentralPanel::default().show(ctx, |_ui| {});
164149
return;
165150
}
166151

167-
// ── Region 2: lanes (the only vertical-scroll surface) ───────
168-
render_clipped(ui, lanes_rect, "mix_lanes", |ui| {
169-
lanes_view(app, ui);
170-
});
152+
// ── Bottom: console deck (fixed height) ──────────────────────
153+
egui::TopBottomPanel::bottom("mix_console_panel")
154+
.resizable(false)
155+
.exact_height(console_h)
156+
.show(ctx, |ui| {
157+
console_deck(app, ui);
158+
});
171159

172-
// ── Region 3: console deck (horizontal-scroll only) ──────────
173-
render_clipped(ui, console_rect, "mix_console", |ui| {
174-
console_deck(app, ui);
160+
// ── Central: lanes ───────────────────────────────────────────
161+
egui::CentralPanel::default().show(ctx, |ui| {
162+
lanes_view(app, ui);
175163
});
176-
177-
// Tell the parent ui we consumed the whole outer rect so it
178-
// doesn't think the cursor is back at the top — keeps any
179-
// sibling layout (none today, but defensively) honest.
180-
ui.allocate_rect(outer, egui::Sense::hover());
181-
}
182-
183-
/// Build a `child_ui` clamped to `rect`, with `clip_rect = rect` set
184-
/// so any content drawn beyond the boundary is physically hard-
185-
/// clipped. v0.4.30 — replaces the previous nested-`show_inside`
186-
/// approach which leaked the first lane row above the host area
187-
/// when egui's CentralPanel-inside-CentralPanel interaction
188-
/// misfired.
189-
fn render_clipped<R>(
190-
parent: &mut egui::Ui,
191-
rect: Rect,
192-
id_source: &str,
193-
add_contents: impl FnOnce(&mut egui::Ui) -> R,
194-
) -> R {
195-
let mut child = parent.child_ui_with_id_source(
196-
rect,
197-
egui::Layout::top_down(egui::Align::Min),
198-
id_source,
199-
None,
200-
);
201-
child.set_clip_rect(rect);
202-
// `set_max_size` so any ScrollArea inside knows its viewport.
203-
child.set_max_size(rect.size());
204-
add_contents(&mut child)
205164
}
206165

207166
/// Lazy-rebuild the player when needed (project changed shape OR

0 commit comments

Comments
 (0)