From e635c2925d4acc5fdac73f1e5643f3eddefe1c7c Mon Sep 17 00:00:00 2001 From: Ed_ Date: Mon, 9 Mar 2026 01:19:16 -0400 Subject: [PATCH] feat(theme): Implement comprehensive CRT Filter (scanlines, vignette, noise) --- src/gui_2.py | 22 +++++++----- src/theme_nerv_fx.py | 26 ++++++++++++-- tests/test_theme_nerv_fx.py | 68 ++++++++++++++++++++++++++++--------- 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/src/gui_2.py b/src/gui_2.py index c7b47b3..61cbce7 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -131,7 +131,8 @@ class App: self._token_stats: dict[str, Any] = {} self._token_stats_dirty: bool = True self.perf_history: dict[str, list] = {"frame_time": [0.0] * 100, "fps": [0.0] * 100} - self._nerv_scanlines = theme_fx.ScanlineOverlay() + self._nerv_crt = theme_fx.CRTFilter() + self.ui_crt_filter = True self._nerv_alert = theme_fx.AlertPulsing() self._nerv_flicker = theme_fx.StatusFlicker() @@ -307,9 +308,8 @@ class App: ws = imgui.get_io().display_size self._nerv_alert.update(self.ai_status) self._nerv_alert.render(ws.x, ws.y) - self._nerv_scanlines.render(ws.x, ws.y) - - pushed_prior_tint = False + self._nerv_crt.enabled = self.ui_crt_filter + self._nerv_crt.render(ws.x, ws.y) if self.perf_profiling_enabled: self.perf_monitor.start_component("_gui_func") if self.is_viewing_prior_session: imgui.push_style_color(imgui.Col_.window_bg, vec4(50, 40, 20)) @@ -2895,17 +2895,21 @@ def hello(): ch_ct, ctrans = imgui.slider_float("##ctrans", theme.get_child_transparency(), 0.1, 1.0, "%.2f") if ch_ct: theme.set_child_transparency(ctrans) - self._flush_to_config() - models.save_config(self.config) - - imgui.separator() - bg = bg_shader.get_bg() ch_bg, bg.enabled = imgui.checkbox("Animated Background Shader", bg.enabled) if ch_bg: gui_cfg = self.config.setdefault("gui", {}) gui_cfg["bg_shader_enabled"] = bg.enabled self._flush_to_config() models.save_config(self.config) + + ch_crt, self.ui_crt_filter = imgui.checkbox("CRT Filter", self.ui_crt_filter) + if ch_crt: + gui_cfg = self.config.setdefault("gui", {}) + gui_cfg["crt_filter_enabled"] = self.ui_crt_filter + self._flush_to_config() + models.save_config(self.config) + self._flush_to_config() + models.save_config(self.config) imgui.end() if self.perf_profiling_enabled: self.perf_monitor.end_component("_render_theme_panel") diff --git a/src/theme_nerv_fx.py b/src/theme_nerv_fx.py index 919bffe..87a4d4b 100644 --- a/src/theme_nerv_fx.py +++ b/src/theme_nerv_fx.py @@ -1,8 +1,9 @@ import time import math +import random from imgui_bundle import imgui -class ScanlineOverlay: +class CRTFilter: def __init__(self): self.enabled = True @@ -10,9 +11,28 @@ class ScanlineOverlay: if not self.enabled: return draw_list = imgui.get_foreground_draw_list() - color = imgui.get_color_u32((0.0, 0.0, 0.0, 0.06)) + + # Scanlines + scanline_color = imgui.get_color_u32((0.0, 0.0, 0.0, 0.06)) for y in range(0, int(height), 2): - draw_list.add_line((0.0, float(y)), (float(width), float(y)), color, 1.0) + draw_list.add_line((0.0, float(y)), (float(width), float(y)), scanline_color, 1.0) + + # Subtle Vignette + v_steps = 15 + for i in range(v_steps): + alpha = (i / v_steps) ** 2.0 * 0.15 + v_color = imgui.get_color_u32((0.0, 0.0, 0.0, alpha)) + inset = (v_steps - i) * 6.0 + if width > inset * 2.0 and height > inset * 2.0: + draw_list.add_rect((inset, inset), (width - inset, height - inset), v_color, 40.0, 0, 10.0) + + # Subtle Random Noise + for _ in range(30): + nx = random.uniform(0.0, width) + ny = random.uniform(0.0, height) + n_alpha = random.uniform(0.01, 0.03) + n_color = imgui.get_color_u32((1.0, 1.0, 1.0, n_alpha)) + draw_list.add_rect_filled((nx, ny), (nx + 1.0, ny + 1.0), n_color) class StatusFlicker: def get_alpha(self) -> float: diff --git a/tests/test_theme_nerv_fx.py b/tests/test_theme_nerv_fx.py index ea3bf6a..49676a8 100644 --- a/tests/test_theme_nerv_fx.py +++ b/tests/test_theme_nerv_fx.py @@ -1,30 +1,45 @@ import unittest from unittest.mock import MagicMock, patch import math -from src.theme_nerv_fx import ScanlineOverlay, StatusFlicker +from src.theme_nerv_fx import CRTFilter, StatusFlicker, AlertPulsing class TestThemeNervFx(unittest.TestCase): @patch("src.theme_nerv_fx.imgui") - def test_scanline_overlay_render(self, mock_imgui): + def test_crt_filter_render(self, mock_imgui): # Setup mock_draw_list = MagicMock() mock_imgui.get_foreground_draw_list.return_value = mock_draw_list mock_imgui.get_color_u32.return_value = 0x12345678 - overlay = ScanlineOverlay() - width, height = 100.0, 10.0 + overlay = CRTFilter() + width, height = 800.0, 600.0 # Act overlay.render(width, height) # Assert mock_imgui.get_foreground_draw_list.assert_called_once() - # height is 10, range(0, 10, 2) is [0, 2, 4, 6, 8] -> 5 calls - self.assertEqual(mock_draw_list.add_line.call_count, 5) + # height is 600, range(0, 600, 2) is 300 calls + self.assertEqual(mock_draw_list.add_line.call_count, 300) + # Vignette: v_steps = 15. height=600 is plenty for all insets. + self.assertEqual(mock_draw_list.add_rect.call_count, 15) + # Noise: 30 calls to add_rect_filled + self.assertEqual(mock_draw_list.add_rect_filled.call_count, 30) # Verify some calls - mock_draw_list.add_line.assert_any_call((0.0, 0.0), (100.0, 0.0), 0x12345678, 1.0) - mock_draw_list.add_line.assert_any_call((0.0, 8.0), (100.0, 8.0), 0x12345678, 1.0) + mock_draw_list.add_line.assert_any_call((0.0, 0.0), (800.0, 0.0), 0x12345678, 1.0) + mock_draw_list.add_line.assert_any_call((0.0, 598.0), (800.0, 598.0), 0x12345678, 1.0) + + @patch("src.theme_nerv_fx.imgui") + def test_crt_filter_disabled(self, mock_imgui): + mock_draw_list = MagicMock() + mock_imgui.get_foreground_draw_list.return_value = mock_draw_list + + overlay = CRTFilter() + overlay.enabled = False + overlay.render(800.0, 600.0) + + mock_imgui.get_foreground_draw_list.assert_not_called() @patch("src.theme_nerv_fx.time") def test_status_flicker_get_alpha(self, mock_time): @@ -36,21 +51,42 @@ class TestThemeNervFx(unittest.TestCase): self.assertAlmostEqual(flicker.get_alpha(), 0.85) # sin(pi/2) = 1 -> 1.0 - # time * 20.0 = pi/2 -> time = pi/40 mock_time.time.return_value = math.pi / 40.0 self.assertAlmostEqual(flicker.get_alpha(), 1.0) # sin(3pi/2) = -1 -> 0.7 - # time * 20.0 = 3pi/2 -> time = 3pi/40 mock_time.time.return_value = 3 * math.pi / 40.0 self.assertAlmostEqual(flicker.get_alpha(), 0.7) + + def test_alert_pulsing_update(self): + pulse = AlertPulsing() + self.assertFalse(pulse.active) - # Verify range for many samples - for i in range(100): - mock_time.time.return_value = i * 0.1 - alpha = flicker.get_alpha() - self.assertGreaterEqual(alpha, 0.7) - self.assertLessEqual(alpha, 1.0) + pulse.update("error: something failed") + self.assertTrue(pulse.active) + + pulse.update("Error: alert") + self.assertTrue(pulse.active) + + pulse.update("idle") + self.assertFalse(pulse.active) + + @patch("src.theme_nerv_fx.imgui") + @patch("src.theme_nerv_fx.time") + def test_alert_pulsing_render(self, mock_time, mock_imgui): + mock_draw_list = MagicMock() + mock_imgui.get_foreground_draw_list.return_value = mock_draw_list + mock_imgui.get_color_u32.return_value = 0xFF0000FF + + pulse = AlertPulsing() + pulse.active = True + + # sin(0) = 0 -> alpha = 0.05 + 0.15 * (0+1)/2 = 0.125 + mock_time.time.return_value = 0.0 + pulse.render(800.0, 600.0) + + mock_imgui.get_foreground_draw_list.assert_called() + mock_draw_list.add_rect.assert_called_with((0.0, 0.0), (800.0, 600.0), 0xFF0000FF, 0.0, 0, 10.0) if __name__ == "__main__": unittest.main()