diff --git a/src/mini_eq/window_graph.py b/src/mini_eq/window_graph.py index a25e250..2a6c6ad 100644 --- a/src/mini_eq/window_graph.py +++ b/src/mini_eq/window_graph.py @@ -6,13 +6,16 @@ import gi gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") -from gi.repository import GLib, Gtk +from gi.repository import Gdk, GLib, Gtk from .analyzer import analyzer_db_to_display_norm from .appearance import style_manager_is_dark from .core import ( DEFAULT_BAND_Q, + EQ_Q_MAX, + EQ_Q_MIN, FILTER_TYPE_INDEX_BY_VALUE, FILTER_TYPE_ORDER, FILTER_TYPES, @@ -23,6 +26,7 @@ MAX_BANDS, MODE_INDEX_BY_VALUE, SAMPLE_RATE, + EqBand, band_is_effective, bands_have_solo, clamp, @@ -332,12 +336,12 @@ def sync_ui_from_state(self) -> None: def on_graph_pressed(self, gesture: Gtk.GestureClick, _press_count: int, x: float, _y: float) -> None: width = self.graph_area.get_allocated_width() - if width <= 0: + height = self.graph_area.get_allocated_height() + if width <= 0 or height <= 0: return - plot_left = 58.0 - plot_right = 52.0 - freq = self.x_to_frequency(x, width, plot_left, plot_right) + width_f, height_f, left, right, top, bottom = self.graph_plot_bounds(width, height) + freq = self.x_to_frequency(x, width_f, left, right) visible_limit = self.visible_band_limit() visible_active = [index for index in self.active_band_indexes() if index < visible_limit] candidates = visible_active or list(range(visible_limit)) @@ -347,6 +351,131 @@ def on_graph_pressed(self, gesture: Gtk.GestureClick, _press_count: int, x: floa ) self.select_band(target) + def on_graph_drag_begin(self, gesture: Gtk.GestureDrag, start_x: float, start_y: float) -> None: + width = self.graph_area.get_allocated_width() + height = self.graph_area.get_allocated_height() + if width <= 0 or height <= 0: + return + + width_f, height_f, left, right, top, bottom = self.graph_plot_bounds(width, height) + + visible_limit = self.visible_band_limit() + active = [index for index in self.active_band_indexes() if index < visible_limit] + if not active: + active = list(range(visible_limit)) + + best_index = -1 + min_dist = float("inf") + + for index in active: + band = self.controller.bands[index] + bx = self.frequency_to_x(band.frequency, width_f, left, right) + by = self.db_to_y( + total_response_db(self.controller.bands, self.controller.preamp_db, SAMPLE_RATE, band.frequency), + height_f, + top, + bottom, + ) + dist = math.sqrt((bx - start_x) ** 2 + (by - start_y) ** 2) + if dist < min_dist: + min_dist = dist + best_index = index + + if min_dist < 32.0: + self.drag_band_index = best_index + self.drag_start_q = self.controller.bands[best_index].q + self.select_band(best_index) + else: + self.drag_band_index = None + self.drag_start_q = None + + def on_graph_drag_update(self, gesture: Gtk.GestureDrag, offset_x: float, offset_y: float) -> None: + drag_index = getattr(self, "drag_band_index", None) + if drag_index is None: + return + + width = self.graph_area.get_allocated_width() + height = self.graph_area.get_allocated_height() + if width <= 0 or height <= 0: + return + + width_f, height_f, left, right, top, bottom = self.graph_plot_bounds(width, height) + + success, start_x, start_y = gesture.get_start_point() + if not success: + return + + state = gesture.get_current_event_state() + is_shift = (state & Gdk.ModifierType.SHIFT_MASK) != 0 + + bands = self.controller.bands + band = bands[drag_index] + + changed_f = False + changed_g = False + changed_q = False + + if is_shift: + # Shift + Vertical -> Q adjustment (isolated) + start_q = getattr(self, "drag_start_q", band.q) + new_q = clamp(start_q - (offset_y * 0.005), EQ_Q_MIN, EQ_Q_MAX) + changed_q = self.controller.set_band_q(drag_index, new_q, apply=False) + else: + # No shift -> Frequency and Gain adjustment + curr_x = start_x + offset_x + freq = self.x_to_frequency(curr_x, width_f, left, right) + changed_f = self.controller.set_band_frequency(drag_index, freq, apply=False) + + # Gain adjustment (only for gain-capable filters) + if band.filter_type in {FILTER_TYPES["Bell"], FILTER_TYPES["Hi-shelf"], FILTER_TYPES["Lo-shelf"]}: + curr_y = start_y + offset_y + target_db = self.y_to_db(curr_y, height_f, top, bottom) + + # To make the point stay under the mouse on the combined curve, we calculate + # the gain needed for this band by subtracting the contribution of all other bands. + # We preserve the full band list to maintain solo context during calculations. + temp_bands = [ + EqBand( + filter_type=FILTER_TYPES["Off"] if i == drag_index else b.filter_type, + frequency=b.frequency, + gain_db=b.gain_db, + q=b.q, + mode=b.mode, + slope=b.slope, + mute=b.mute, + solo=b.solo, + ) + for i, b in enumerate(bands) + ] + db_others = total_response_db(temp_bands, self.controller.preamp_db, SAMPLE_RATE, freq) + + # Required gain for this band at the current mouse frequency + new_gain = target_db - db_others + + # Adjustment for shelf filters: at center frequency, a shelf provides half its gain in dB. + if band.filter_type in {FILTER_TYPES["Lo-shelf"], FILTER_TYPES["Hi-shelf"]}: + new_gain *= 2.0 + + changed_g = self.controller.set_band_gain(drag_index, new_gain, apply=False) + + if changed_f or changed_g or changed_q: + self.schedule_band_engine_update(drag_index) + self.selected_band_index = drag_index + self.updating_ui = True + try: + self.update_band_fader(drag_index) + self.update_focus_summary() + self.update_selected_band_editor() + finally: + self.updating_ui = False + + self.invalidate_graph_response_cache() + self.queue_response_draw() + self.schedule_curve_metadata_refresh() + + def on_graph_drag_end(self, _gesture: Gtk.GestureDrag, _offset_x: float, _offset_y: float) -> None: + self.drag_band_index = None + def on_preamp_changed(self, scale: Gtk.Scale) -> None: value = scale.get_value() self.preamp_label.set_text(f"{value:.1f} dB") @@ -573,6 +702,11 @@ def db_to_y(self, db_value: float, height: float, top: float, bottom: float) -> normalized = (clamp(db_value, GRAPH_DB_MIN, GRAPH_DB_MAX) - GRAPH_DB_MIN) / (GRAPH_DB_MAX - GRAPH_DB_MIN) return (height - bottom) - (usable * normalized) + def y_to_db(self, y: float, height: float, top: float, bottom: float) -> float: + usable = max(height - top - bottom, 1.0) + normalized = clamp(((height - bottom) - y) / usable, 0.0, 1.0) + return GRAPH_DB_MIN + normalized * (GRAPH_DB_MAX - GRAPH_DB_MIN) + def analyzer_display_db_to_y(self, display_db: float, height: float, top: float, bottom: float) -> float: usable = max(height - top - bottom, 1.0) normalized = analyzer_db_to_display_norm(display_db) diff --git a/src/mini_eq/window_layout.py b/src/mini_eq/window_layout.py index bb4efa2..0817096 100644 --- a/src/mini_eq/window_layout.py +++ b/src/mini_eq/window_layout.py @@ -385,6 +385,13 @@ def sync_compact_toolbar(_widget: Gtk.Widget | None = None, _param: object | Non graph_click = Gtk.GestureClick() graph_click.connect("pressed", self.on_graph_pressed) self.graph_area.add_controller(graph_click) + + graph_drag = Gtk.GestureDrag() + graph_drag.connect("drag-begin", self.on_graph_drag_begin) + graph_drag.connect("drag-update", self.on_graph_drag_update) + graph_drag.connect("drag-end", self.on_graph_drag_end) + self.graph_area.add_controller(graph_drag) + graph_overlay.set_child(self.graph_area) self.analyzer_area = AnalyzerPlotWidget() diff --git a/tests/test_mini_eq_window_graph.py b/tests/test_mini_eq_window_graph.py index f3d6649..e3c5eee 100644 --- a/tests/test_mini_eq_window_graph.py +++ b/tests/test_mini_eq_window_graph.py @@ -2,6 +2,8 @@ from types import SimpleNamespace +import pytest + from tests._mini_eq_imports import import_mini_eq_module core = import_mini_eq_module("core") @@ -94,6 +96,117 @@ def get_value(self) -> float: return self.value +class FakeGraphArea: + def __init__(self, width: int = 640, height: int = 240) -> None: + self.width = width + self.height = height + + def get_allocated_width(self) -> int: + return self.width + + def get_allocated_height(self) -> int: + return self.height + + +class FakeGraphDragGesture: + def __init__(self, start_x: float, start_y: float, state=0) -> None: + self.start_x = start_x + self.start_y = start_y + self.state = state + + def get_start_point(self) -> tuple[bool, float, float]: + return True, self.start_x, self.start_y + + def get_current_event_state(self): + return self.state + + +class FakeGraphController: + def __init__(self, bands: list[core.EqBand], preamp_db: float = 0.0) -> None: + self.bands = bands + self.preamp_db = preamp_db + self.frequency_updates: list[tuple[int, float]] = [] + self.gain_updates: list[tuple[int, float]] = [] + self.q_updates: list[tuple[int, float]] = [] + + def set_band_frequency(self, index: int, frequency: float, *, apply: bool = True) -> bool: + self.frequency_updates.append((index, frequency)) + if self.bands[index].frequency == frequency: + return False + self.bands[index].frequency = frequency + return True + + def set_band_gain(self, index: int, gain_db: float, *, apply: bool = True) -> bool: + self.gain_updates.append((index, gain_db)) + if self.bands[index].gain_db == gain_db: + return False + self.bands[index].gain_db = gain_db + return True + + def set_band_q(self, index: int, q_value: float, *, apply: bool = True) -> bool: + self.q_updates.append((index, q_value)) + if self.bands[index].q == q_value: + return False + self.bands[index].q = q_value + return True + + +class GraphInteractionWindow(window_graph.MiniEqWindowGraphMixin): + def __init__(self, bands: list[core.EqBand]) -> None: + self.controller = FakeGraphController(bands) + self.graph_area = FakeGraphArea() + self.visible_band_count = len(bands) + self.selected_band_index = None + self.updating_ui = False + self.drag_band_index = None + self.drag_start_q = None + self.engine_updates: list[int] = [] + self.ui_updates: list[object] = [] + + def select_band(self, index: int) -> None: + self.selected_band_index = index + + def schedule_band_engine_update(self, index: int) -> None: + self.engine_updates.append(index) + + def update_band_fader(self, index: int, solo_active: bool | None = None) -> None: + self.ui_updates.append(("fader", index)) + + def update_focus_summary(self) -> None: + self.ui_updates.append("focus") + + def update_selected_band_editor(self) -> None: + self.ui_updates.append("editor") + + def invalidate_graph_response_cache(self) -> None: + self.ui_updates.append("invalidate") + + def queue_response_draw(self) -> None: + self.ui_updates.append("draw") + + def schedule_curve_metadata_refresh(self) -> None: + self.ui_updates.append("metadata") + + def band_point(self, index: int) -> tuple[float, float]: + width = self.graph_area.get_allocated_width() + height = self.graph_area.get_allocated_height() + width_f, height_f, left, right, top, bottom = self.graph_plot_bounds(width, height) + band = self.controller.bands[index] + x = self.frequency_to_x(band.frequency, width_f, left, right) + y = self.db_to_y( + window_graph.total_response_db( + self.controller.bands, + self.controller.preamp_db, + core.SAMPLE_RATE, + band.frequency, + ), + height_f, + top, + bottom, + ) + return x, y + + class FocusSummaryWindow(window_graph.MiniEqWindowGraphMixin): def __init__( self, @@ -306,3 +419,87 @@ def test_curve_metadata_refresh_idle_notifies_control_clients() -> None: assert keep_source is False assert test_window.curve_metadata_refresh_source_id == 0 assert calls == ["status", "preset-state", "control-state"] + + +def test_graph_press_uses_shared_plot_bounds_for_frequency_mapping() -> None: + window = GraphInteractionWindow( + [ + core.EqBand(core.FILTER_TYPES["Bell"], 100.0), + core.EqBand(core.FILTER_TYPES["Bell"], 1000.0), + ] + ) + calls: list[tuple[float, float, float, float]] = [] + + def graph_plot_bounds(width: int, height: int) -> tuple[float, float, float, float, float, float]: + assert (width, height) == (640, 240) + return 640.0, 240.0, 11.0, 77.0, 13.0, 17.0 + + def x_to_frequency(x: float, width: float, left: float, right: float) -> float: + calls.append((x, width, left, right)) + return 1000.0 + + window.graph_plot_bounds = graph_plot_bounds + window.x_to_frequency = x_to_frequency + + window.on_graph_pressed(None, 1, 240.0, 120.0) + + assert calls == [(240.0, 640.0, 11.0, 77.0)] + assert window.selected_band_index == 1 + + +def test_graph_drag_preserves_solo_context_when_calculating_other_response() -> None: + window = GraphInteractionWindow( + [ + core.EqBand(core.FILTER_TYPES["Bell"], 1000.0, gain_db=6.0, solo=True), + core.EqBand(core.FILTER_TYPES["Bell"], 1000.0, gain_db=6.0), + ] + ) + start_x, start_y = window.band_point(0) + window.drag_band_index = 0 + window.drag_start_q = window.controller.bands[0].q + + window.on_graph_drag_update(FakeGraphDragGesture(start_x, start_y), 0.0, 0.0) + + assert window.controller.gain_updates + assert window.controller.bands[0].gain_db == pytest.approx(6.0) + + +def test_graph_drag_does_not_change_gain_for_non_gain_filters() -> None: + window = GraphInteractionWindow( + [ + core.EqBand(core.FILTER_TYPES["Hi-pass"], 1000.0, gain_db=3.0), + ] + ) + start_x, start_y = window.band_point(0) + window.drag_band_index = 0 + window.drag_start_q = window.controller.bands[0].q + + window.on_graph_drag_update(FakeGraphDragGesture(start_x, start_y), 40.0, 80.0) + + assert window.controller.frequency_updates + assert window.controller.gain_updates == [] + assert window.controller.bands[0].gain_db == 3.0 + + +def test_graph_shift_drag_changes_q_without_changing_frequency_or_gain() -> None: + window = GraphInteractionWindow( + [ + core.EqBand(core.FILTER_TYPES["Bell"], 1000.0, gain_db=3.0, q=1.0), + ] + ) + start_x, start_y = window.band_point(0) + window.drag_band_index = 0 + window.drag_start_q = 1.0 + + window.on_graph_drag_update( + FakeGraphDragGesture(start_x, start_y, window_graph.Gdk.ModifierType.SHIFT_MASK), + 90.0, + -100.0, + ) + + assert window.controller.frequency_updates == [] + assert window.controller.gain_updates == [] + assert window.controller.q_updates == [(0, pytest.approx(1.5))] + assert window.controller.bands[0].frequency == 1000.0 + assert window.controller.bands[0].gain_db == 3.0 + assert window.controller.bands[0].q == pytest.approx(1.5)