Skip to content

Commit b243f6d

Browse files
tamirelazarclaude
andcommitted
feat: declutter overlays — gate status bar on pause, blank frame behind modals
Two visual-noise fixes for the windowed overlay experience: 1. Status bar now appears only when paused AND no overlay is open. Previously it showed whenever paused OR any overlay was open, cluttering the screen behind modals. on_pause() no longer expands chrome while an overlay is open, and on_modal_open() leaves the default (Minimal) base chrome collapsed instead of forcing ModalPane (persistent Expanded chrome is unchanged). 2. When a modal panel overlaps the window-frame border, the simulation and frame are wiped to a clean matte behind it so the panel is the sole focus. Panels fully contained within the sim interior leave the sim showing, so the blanking only kicks in when the frame would otherwise poke out as noise. Added FrameBuffer::fill_background and TerminalRenderer::modal_overlaps_frame, wired into both the single- and multi-species render paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 305866f commit b243f6d

3 files changed

Lines changed: 208 additions & 61 deletions

File tree

src/terminal/frame_buffer.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,28 @@ impl FrameBuffer {
146146
}
147147
}
148148

149+
/// Resets every cell to the matte background (blank space + background color),
150+
/// wiping any simulation content and window frame already drawn. Used to give
151+
/// a clean backdrop behind a modal overlay that would otherwise overlap the
152+
/// frame border and read as visual noise.
153+
pub fn fill_background(&mut self) {
154+
let (bg_color_256, bg_color_rgb) = match (self.background_color, self.color_mode) {
155+
(Some(bg), ColorMode::TrueColor) => (None, Some(bg)),
156+
(Some(bg), _) => (Some(palette::rgb_to_256(bg)), None),
157+
(None, _) => (None, None),
158+
};
159+
let blank = Cell {
160+
char: ' ',
161+
fg_color_256: None,
162+
bg_color_256,
163+
fg_color_rgb: None,
164+
bg_color_rgb,
165+
};
166+
for cell in &mut self.cells {
167+
*cell = blank;
168+
}
169+
}
170+
149171
#[cfg(test)]
150172
pub(crate) fn get_cell(&self, x: usize, y: usize) -> &Cell {
151173
&self.cells[y * self.width + x]

src/terminal/renderer.rs

Lines changed: 145 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,46 @@ impl TerminalRenderer {
640640
}
641641
}
642642

643+
/// Whether a modal panel overlay overlaps the window-frame border, in which
644+
/// case the sim + frame should be wiped to a clean matte behind it.
645+
///
646+
/// Returns false when there is no visible frame (fullscreen / no layout), so
647+
/// the simulation always shows through when there is no border to clutter.
648+
/// A panel "intersects the border" when its rectangle is not fully contained
649+
/// within the sim interior — i.e. it reaches into or past the frame ring.
650+
fn modal_overlaps_frame(&self, panels: &[Option<(&RenderedOverlay, usize, usize)>]) -> bool {
651+
use crate::render::window::FallbackMode;
652+
if !self.window_frame.is_visible() {
653+
return false;
654+
}
655+
let Some(layout) = &self.window_layout else {
656+
return false;
657+
};
658+
if matches!(layout.fallback, FallbackMode::Fullscreen) {
659+
return false;
660+
}
661+
let (sx, sy, sw, sh) = (layout.sim_x, layout.sim_y, layout.sim_w, layout.sim_h);
662+
panels.iter().flatten().any(|(overlay, x, y)| {
663+
let w = overlay
664+
.lines
665+
.iter()
666+
.map(|l| l.chars().count())
667+
.max()
668+
.unwrap_or(0);
669+
let h = overlay.lines.len();
670+
if w == 0 || h == 0 {
671+
return false;
672+
}
673+
// The floating title box sits one row above the body.
674+
let top = y.saturating_sub(usize::from(overlay.title_box.is_some()));
675+
let bottom = y + h;
676+
let right = x + w;
677+
// Fully inside the interior → does not touch the border.
678+
let contained = *x >= sx && top >= sy && right <= sx + sw && bottom <= sy + sh;
679+
!contained
680+
})
681+
}
682+
643683
/// Render a frame with text overlays.
644684
///
645685
/// Draws the sim frame, then composites overlays on top in z-order:
@@ -760,39 +800,64 @@ impl TerminalRenderer {
760800
buffer.apply_pause_effect(pause_style, fc, pause_pulse_draw_mode);
761801
}
762802

763-
if let Some(mut grid) = grid_renderer.cloned() {
764-
grid.initialize(self.width, self.height);
765-
766-
// Calculate average brightness for adaptive opacity
767-
let total_brightness: f32 = downsampled
768-
.iter()
769-
.map(|cell| cell.top.max(cell.bottom))
770-
.sum();
771-
let avg_brightness = if !downsampled.is_empty() && max_trail_value > 0.0 {
772-
(total_brightness / (downsampled.len() as f32)) / max_trail_value
773-
} else {
774-
0.0
775-
};
803+
// When a modal panel overlaps the window-frame border, wipe the sim + frame
804+
// to a clean matte so the panel reads as the sole focus instead of fighting
805+
// the border and simulation behind it.
806+
let blank_backdrop = self.modal_overlaps_frame(&[
807+
controls_lines,
808+
dashboard_lines,
809+
config_browser_lines,
810+
config_save_lines,
811+
dirty_guard_lines,
812+
keyboard_hints_lines,
813+
preset_comparison_lines,
814+
palette_editor_overlay,
815+
]);
816+
817+
if !blank_backdrop {
818+
if let Some(mut grid) = grid_renderer.cloned() {
819+
grid.initialize(self.width, self.height);
820+
821+
// Calculate average brightness for adaptive opacity
822+
let total_brightness: f32 = downsampled
823+
.iter()
824+
.map(|cell| cell.top.max(cell.bottom))
825+
.sum();
826+
let avg_brightness = if !downsampled.is_empty() && max_trail_value > 0.0 {
827+
(total_brightness / (downsampled.len() as f32)) / max_trail_value
828+
} else {
829+
0.0
830+
};
776831

777-
for y in 0..self.height {
778-
for x in 0..self.width {
779-
if grid.is_grid_position(x, y, self.width, self.height) {
780-
let (on_vertical, on_horizontal) = grid.get_grid_lines(x, y);
781-
let opacity =
782-
grid.calculate_opacity(x, y, self.width, self.height, avg_brightness);
783-
buffer.render_grid_background(
784-
x,
785-
y,
786-
grid.color,
787-
opacity,
788-
on_vertical,
789-
on_horizontal,
790-
);
832+
for y in 0..self.height {
833+
for x in 0..self.width {
834+
if grid.is_grid_position(x, y, self.width, self.height) {
835+
let (on_vertical, on_horizontal) = grid.get_grid_lines(x, y);
836+
let opacity = grid.calculate_opacity(
837+
x,
838+
y,
839+
self.width,
840+
self.height,
841+
avg_brightness,
842+
);
843+
buffer.render_grid_background(
844+
x,
845+
y,
846+
grid.color,
847+
opacity,
848+
on_vertical,
849+
on_horizontal,
850+
);
851+
}
791852
}
792853
}
793854
}
794855
}
795856

857+
if blank_backdrop {
858+
buffer.fill_background();
859+
}
860+
796861
// Palette accent color used for accented title badges and border.
797862
let accent = palette::palette_accent_color(
798863
&self.palette,
@@ -802,7 +867,7 @@ impl TerminalRenderer {
802867
self.intensity_mapping.as_ref(),
803868
);
804869

805-
if self.window_frame.is_visible() {
870+
if !blank_backdrop && self.window_frame.is_visible() {
806871
if let Some(ref layout) = self.window_layout {
807872
use crate::render::window::FallbackMode;
808873
if !matches!(layout.fallback, FallbackMode::Fullscreen) {
@@ -1180,39 +1245,63 @@ impl TerminalRenderer {
11801245
buffer.apply_pause_effect(pause_style, fc, pause_pulse_draw_mode);
11811246
}
11821247

1183-
if let Some(mut grid) = grid_renderer.cloned() {
1184-
grid.initialize(self.width, self.height);
1185-
1186-
// Calculate average brightness from all species combined
1187-
let total_brightness: f32 = all_downsampled_cells
1188-
.iter()
1189-
.map(|cell| cell.top.max(cell.bottom))
1190-
.sum();
1191-
let avg_brightness = if !all_downsampled_cells.is_empty() && max_trail_value > 0.0 {
1192-
(total_brightness / (all_downsampled_cells.len() as f32)) / max_trail_value
1193-
} else {
1194-
0.0
1195-
};
1248+
// See render_with_overlay: blank the sim + frame when a modal panel overlaps
1249+
// the window-frame border, so the panel is the sole focus.
1250+
let blank_backdrop = self.modal_overlaps_frame(&[
1251+
controls_lines,
1252+
dashboard_lines,
1253+
config_browser_lines,
1254+
config_save_lines,
1255+
dirty_guard_lines,
1256+
keyboard_hints_lines,
1257+
preset_comparison_lines,
1258+
palette_editor_overlay,
1259+
]);
1260+
1261+
if !blank_backdrop {
1262+
if let Some(mut grid) = grid_renderer.cloned() {
1263+
grid.initialize(self.width, self.height);
1264+
1265+
// Calculate average brightness from all species combined
1266+
let total_brightness: f32 = all_downsampled_cells
1267+
.iter()
1268+
.map(|cell| cell.top.max(cell.bottom))
1269+
.sum();
1270+
let avg_brightness = if !all_downsampled_cells.is_empty() && max_trail_value > 0.0 {
1271+
(total_brightness / (all_downsampled_cells.len() as f32)) / max_trail_value
1272+
} else {
1273+
0.0
1274+
};
11961275

1197-
for y in 0..self.height {
1198-
for x in 0..self.width {
1199-
if grid.is_grid_position(x, y, self.width, self.height) {
1200-
let (on_vertical, on_horizontal) = grid.get_grid_lines(x, y);
1201-
let opacity =
1202-
grid.calculate_opacity(x, y, self.width, self.height, avg_brightness);
1203-
buffer.render_grid_background(
1204-
x,
1205-
y,
1206-
grid.color,
1207-
opacity,
1208-
on_vertical,
1209-
on_horizontal,
1210-
);
1276+
for y in 0..self.height {
1277+
for x in 0..self.width {
1278+
if grid.is_grid_position(x, y, self.width, self.height) {
1279+
let (on_vertical, on_horizontal) = grid.get_grid_lines(x, y);
1280+
let opacity = grid.calculate_opacity(
1281+
x,
1282+
y,
1283+
self.width,
1284+
self.height,
1285+
avg_brightness,
1286+
);
1287+
buffer.render_grid_background(
1288+
x,
1289+
y,
1290+
grid.color,
1291+
opacity,
1292+
on_vertical,
1293+
on_horizontal,
1294+
);
1295+
}
12111296
}
12121297
}
12131298
}
12141299
}
12151300

1301+
if blank_backdrop {
1302+
buffer.fill_background();
1303+
}
1304+
12161305
// Palette accent color for accented title badges (same approach as single-species).
12171306
let accent = palette::palette_accent_color(
12181307
&self.palette,
@@ -1222,7 +1311,7 @@ impl TerminalRenderer {
12221311
self.intensity_mapping.as_ref(),
12231312
);
12241313

1225-
if self.window_frame.is_visible() {
1314+
if !blank_backdrop && self.window_frame.is_visible() {
12261315
if let Some(ref layout) = self.window_layout {
12271316
use crate::render::window::FallbackMode;
12281317
if !matches!(layout.fallback, FallbackMode::Fullscreen) {

src/terminal/state.rs

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1716,11 +1716,14 @@ impl RuntimeState {
17161716

17171717
/// Called when simulation is paused.
17181718
///
1719-
/// If the base chrome state is Minimal, expands chrome to show title + footer.
1719+
/// If the base chrome state is Minimal, expands chrome to show title + footer
1720+
/// — but only when no overlay is open. An open overlay owns the screen, and
1721+
/// the status bar would just clutter behind it, so the chrome stays collapsed
1722+
/// (the status bar appears on pause **and** no-overlay only).
17201723
/// If base is Expanded, stays Expanded (no change needed).
17211724
/// Also cancels any in-progress fade-out, snapping back to Expanded.
17221725
pub fn on_pause(&mut self) {
1723-
if self.base_chrome_state == ChromeState::Minimal {
1726+
if self.base_chrome_state == ChromeState::Minimal && !self.any_overlay_open() {
17241727
self.chrome_state = ChromeState::Expanded; // cancels any fade
17251728
}
17261729
}
@@ -1773,9 +1776,16 @@ impl RuntimeState {
17731776

17741777
/// Called when a modal pane is opened.
17751778
///
1776-
/// Always sets chrome to ModalPane regardless of base state.
1779+
/// The overlay owns the screen. With persistent (Expanded) base chrome the
1780+
/// title + footer stay visible as ModalPane (with modal-navigation keybinds);
1781+
/// with the default Minimal base, chrome collapses to Minimal so the status
1782+
/// bar / title block don't clutter behind the overlay.
17771783
pub fn on_modal_open(&mut self) {
1778-
self.chrome_state = ChromeState::ModalPane;
1784+
if self.base_chrome_state == ChromeState::Expanded {
1785+
self.chrome_state = ChromeState::ModalPane;
1786+
} else {
1787+
self.chrome_state = ChromeState::Minimal;
1788+
}
17791789
}
17801790

17811791
/// Called when the last modal pane is closed.
@@ -2502,13 +2512,39 @@ mod tests {
25022512
}
25032513

25042514
#[test]
2505-
fn test_chrome_base_minimal_modal_open() {
2515+
fn test_chrome_base_minimal_modal_open_stays_collapsed() {
2516+
// Default (Minimal) base: opening an overlay keeps chrome collapsed so the
2517+
// status bar does not clutter behind it.
25062518
let mut state = create_test_runtime_state();
25072519
state.base_chrome_state = ChromeState::Minimal;
2520+
state.chrome_state = ChromeState::Minimal;
2521+
state.on_modal_open();
2522+
assert_eq!(state.chrome_state, ChromeState::Minimal);
2523+
}
2524+
2525+
#[test]
2526+
fn test_chrome_base_expanded_modal_open_is_modal_pane() {
2527+
// Persistent (Expanded) base: an overlay shows ModalPane chrome.
2528+
let mut state = create_test_runtime_state();
2529+
state.base_chrome_state = ChromeState::Expanded;
25082530
state.on_modal_open();
25092531
assert_eq!(state.chrome_state, ChromeState::ModalPane);
25102532
}
25112533

2534+
#[test]
2535+
fn test_chrome_pause_with_overlay_open_stays_collapsed() {
2536+
// Status bar appears on (pause AND no overlay): pausing while an overlay is
2537+
// open must NOT expand chrome.
2538+
let mut state = create_test_runtime_state();
2539+
state.base_chrome_state = ChromeState::Minimal;
2540+
state.chrome_state = ChromeState::Minimal;
2541+
state
2542+
.overlay_state
2543+
.open(crate::overlay::OverlayType::KeyboardHints);
2544+
state.on_pause();
2545+
assert_eq!(state.chrome_state, ChromeState::Minimal);
2546+
}
2547+
25122548
#[test]
25132549
fn test_chrome_base_minimal_modal_close_no_pause() {
25142550
let mut state = create_test_runtime_state();

0 commit comments

Comments
 (0)