diff --git a/src/phosphor/__init__.py b/src/phosphor/__init__.py index a63410b..7cb5226 100644 --- a/src/phosphor/__init__.py +++ b/src/phosphor/__init__.py @@ -2,12 +2,14 @@ from .__version__ import __version__ as __version__ from .channel_plot import ChannelPlotWidget +from .controls import ChannelPlotControlsWidget from .scatter_widget import ScatterConfig, ScatterWidget from .spectrum_widget import SpectrumConfig, SpectrumWidget from .sweep_buffer import SweepEvent from .sweep_widget import SweepConfig, SweepWidget __all__ = [ + "ChannelPlotControlsWidget", "ChannelPlotWidget", "ScatterConfig", "ScatterWidget", diff --git a/src/phosphor/channel_plot.py b/src/phosphor/channel_plot.py index 4f8dd61..264268a 100644 --- a/src/phosphor/channel_plot.py +++ b/src/phosphor/channel_plot.py @@ -85,7 +85,9 @@ def _init_rendering(self) -> None: # Register fpl event handlers on the subplot's pygfx renderer renderer = self._subplot.renderer + self._renderer = renderer renderer.add_event_handler(self._on_key_down_event, "key_down") + self._mouse_enabled = True renderer.add_event_handler(self._on_wheel_event, "wheel") renderer.add_event_handler(self._on_pointer_move_event, "pointer_move") @@ -176,12 +178,37 @@ def _on_wheel_event(self, event) -> None: def _on_pointer_move_event(self, event) -> None: self._handle_mouse_move(event) + def set_mouse_enabled(self, enabled: bool) -> None: + """Enable or disable mouse-driven plot interactions (wheel, hover tooltip). + + Keyboard shortcuts and external controls (ChannelPlotControlsWidget) + keep working either way. + """ + if enabled == self._mouse_enabled: + return + self._mouse_enabled = enabled + if enabled: + self._renderer.add_event_handler(self._on_wheel_event, "wheel") + self._renderer.add_event_handler(self._on_pointer_move_event, "pointer_move") + else: + self._renderer.remove_event_handler(self._on_wheel_event, "wheel") + self._renderer.remove_event_handler(self._on_pointer_move_event, "pointer_move") + # ------------------------------------------------------------------ # Amplitude zoom helper # ------------------------------------------------------------------ def _zoom_amplitude(self, factor: float) -> None: - """Adjust camera y-scale and disable autoscale.""" + """Scale the waveform amplitude per row, leaving channel spacing fixed. + + Sweep buffers expose ``set_amplitude_scale`` for this purpose. Other + buffer types fall back to the legacy camera scaling (which also + rescales row offsets, an undesirable side effect). + """ + buf = self._buffer + if hasattr(buf, "set_amplitude_scale"): + buf.set_amplitude_scale(buf.amplitude_scale * factor) + return camera = self._subplot.camera camera.world.scale_y *= factor self._autoscale_enabled = False diff --git a/src/phosphor/constants.py b/src/phosphor/constants.py index f50812e..875ba5a 100644 --- a/src/phosphor/constants.py +++ b/src/phosphor/constants.py @@ -14,7 +14,7 @@ EVENT_COLOR = (1.0, 1.0, 1.0, 1.0) # default white EVENT_THICKNESS = 2.0 -# 10-color repeating palette (RGBA, bright on dark background) +# 10-color repeating palette (RGBA, bright on dark background). CHANNEL_COLORS = [ (1.0, 0.40, 0.40, 1.0), # red (0.40, 1.0, 0.40, 1.0), # green @@ -27,3 +27,20 @@ (1.0, 0.60, 0.80, 1.0), # pink (0.60, 1.0, 0.40, 1.0), # lime ] + +# Lower-saturation alternative palette: less eye fatigue when many channels +# are visible at once. Used as the default for new SweepWidget instances. +SOFT_CHANNEL_COLORS = [ + (0.85, 0.55, 0.55, 1.0), # muted red + (0.55, 0.80, 0.60, 1.0), # muted green + (0.55, 0.70, 0.90, 1.0), # muted blue + (0.85, 0.80, 0.50, 1.0), # muted yellow + (0.55, 0.85, 0.85, 1.0), # muted cyan + (0.80, 0.60, 0.85, 1.0), # muted magenta + (0.85, 0.70, 0.50, 1.0), # muted orange + (0.78, 0.78, 0.78, 1.0), # light grey + (0.85, 0.65, 0.75, 1.0), # muted pink + (0.65, 0.80, 0.55, 1.0), # muted lime +] + +DEFAULT_LINE_THICKNESS = 0.8 diff --git a/src/phosphor/controls.py b/src/phosphor/controls.py new file mode 100644 index 0000000..c68b1c9 --- /dev/null +++ b/src/phosphor/controls.py @@ -0,0 +1,197 @@ +"""Optional Qt controls panel for ChannelPlotWidget. + +Mirrors the keyboard shortcuts defined on ``ChannelPlotWidget`` (and +``SweepWidget``) as on-screen buttons + spinboxes, for users who'd rather +click than memorize keys. + +Usage:: + + plot = SweepWidget(SweepConfig(...)) + controls = ChannelPlotControlsWidget(plot) + layout.addWidget(plot) + layout.addWidget(controls) +""" + +from __future__ import annotations + +from PySide6 import QtCore, QtWidgets + +from .channel_plot import ChannelPlotWidget + +__all__ = ["ChannelPlotControlsWidget"] + + +class ChannelPlotControlsWidget(QtWidgets.QWidget): + """Compact horizontal toolbar of controls for a ChannelPlotWidget.""" + + def __init__( + self, + plot: ChannelPlotWidget, + parent: QtWidgets.QWidget | None = None, + ) -> None: + super().__init__(parent) + self._plot = plot + buf = plot._buffer + if buf is None: + raise ValueError("ChannelPlotWidget must have a buffer before adding controls") + + # Stay tight vertically — this is meant to be a slim toolbar, not a + # panel. Fix the height to the bare minimum. + self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Fixed) + self.setStyleSheet( + "ChannelPlotControlsWidget { font-size: 9pt; }" + "ChannelPlotControlsWidget QToolButton {" + " padding: 0px 4px; min-height: 18px; max-height: 18px;" + " min-width: 18px; font-size: 9pt;" + "}" + "ChannelPlotControlsWidget QSpinBox {" + " padding: 0px 2px; min-height: 18px; max-height: 18px; font-size: 9pt;" + "}" + "ChannelPlotControlsWidget QLabel { font-size: 9pt; color: #9a9aa0; }" + ) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(2, 1, 2, 1) + layout.setSpacing(3) + + # Channel scrolling: up/down by one, and page up/down by n_visible. + layout.addWidget(self._make_label("Channel")) + self._btn_ch_up = self._make_button("\u2191", "Scroll up one channel", self._on_ch_up) + self._btn_ch_down = self._make_button("\u2193", "Scroll down one channel", self._on_ch_down) + self._btn_pg_up = self._make_button("\u21e1", "Page up (n_visible)", self._on_page_up) + self._btn_pg_down = self._make_button("\u21e3", "Page down (n_visible)", self._on_page_down) + layout.addWidget(self._btn_ch_up) + layout.addWidget(self._btn_ch_down) + layout.addWidget(self._btn_pg_up) + layout.addWidget(self._btn_pg_down) + + layout.addWidget(self._make_separator()) + + # Number visible: spinbox plus halve/double shortcuts. + layout.addWidget(self._make_label("Visible")) + self._spin_visible = QtWidgets.QSpinBox() + self._spin_visible.setRange(1, buf.n_channels) + self._spin_visible.setValue(buf.n_visible) + self._spin_visible.setKeyboardTracking(False) + self._spin_visible.setFixedWidth(56) + self._spin_visible.setButtonSymbols(QtWidgets.QAbstractSpinBox.ButtonSymbols.NoButtons) + self._spin_visible.editingFinished.connect(self._on_visible_committed) + layout.addWidget(self._spin_visible) + layout.addWidget(self._make_button("/2", "Halve visible channels", self._on_visible_halve)) + layout.addWidget(self._make_button("x2", "Double visible channels", self._on_visible_double)) + + layout.addWidget(self._make_separator()) + + # Amplitude zoom (per-row waveform scale). + layout.addWidget(self._make_label("Amplitude")) + layout.addWidget(self._make_button("\u2212", "Shrink amplitude", lambda: self._plot._zoom_amplitude(0.8))) + layout.addWidget(self._make_button("+", "Grow amplitude", lambda: self._plot._zoom_amplitude(1.25))) + + # Time zoom — only present if the plot supports it (sweep / spectrum). + if hasattr(plot, "_time_zoom"): + layout.addWidget(self._make_separator()) + layout.addWidget(self._make_label("Time")) + layout.addWidget(self._make_button("\u2212", "Zoom time out (longer span)", lambda: plot._time_zoom(2.0))) + layout.addWidget(self._make_button("+", "Zoom time in (shorter span)", lambda: plot._time_zoom(0.5))) + + layout.addWidget(self._make_separator()) + + # Autoscale toggle. + self._btn_auto = QtWidgets.QToolButton() + self._btn_auto.setText("Auto") + self._btn_auto.setCheckable(True) + self._btn_auto.setChecked(plot._autoscale_enabled) + self._btn_auto.setToolTip("Toggle camera autoscale (key: A)") + self._btn_auto.toggled.connect(self._on_auto_toggled) + layout.addWidget(self._btn_auto) + + layout.addStretch(1) + + # Periodically resync widget state with the buffer (other inputs — + # keyboard, mouse — also mutate it). 200 ms is plenty for a panel. + self._sync_timer = QtCore.QTimer(self) + self._sync_timer.setInterval(200) + self._sync_timer.timeout.connect(self._sync_from_buffer) + self._sync_timer.start() + + # ---- helpers ------------------------------------------------------ + + def _make_button(self, text: str, tip: str, slot) -> QtWidgets.QToolButton: + b = QtWidgets.QToolButton() + b.setText(text) + b.setToolTip(tip) + b.clicked.connect(slot) + return b + + def _make_label(self, text: str) -> QtWidgets.QLabel: + lbl = QtWidgets.QLabel(text) + lbl.setStyleSheet("color: #9a9aa0; font-size: 9pt;") + return lbl + + def _make_separator(self) -> QtWidgets.QFrame: + f = QtWidgets.QFrame() + f.setFrameShape(QtWidgets.QFrame.Shape.VLine) + f.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + return f + + # ---- channel scroll ---------------------------------------------- + + def _on_ch_up(self) -> None: + buf = self._plot._buffer + buf.set_channel_offset(buf.channel_offset - 1) + self._plot._update_range_label() + + def _on_ch_down(self) -> None: + buf = self._plot._buffer + buf.set_channel_offset(buf.channel_offset + 1) + self._plot._update_range_label() + + def _on_page_up(self) -> None: + buf = self._plot._buffer + buf.set_channel_offset(buf.channel_offset - buf.n_visible) + self._plot._update_range_label() + + def _on_page_down(self) -> None: + buf = self._plot._buffer + buf.set_channel_offset(buf.channel_offset + buf.n_visible) + self._plot._update_range_label() + + # ---- n_visible ---------------------------------------------------- + + def _on_visible_committed(self) -> None: + buf = self._plot._buffer + buf.set_n_visible(int(self._spin_visible.value())) + self._plot._update_range_label() + + def _on_visible_halve(self) -> None: + buf = self._plot._buffer + buf.set_n_visible(max(1, buf.n_visible // 2)) + self._plot._update_range_label() + + def _on_visible_double(self) -> None: + buf = self._plot._buffer + buf.set_n_visible(min(buf.n_channels, buf.n_visible * 2)) + self._plot._update_range_label() + + # ---- autoscale ---------------------------------------------------- + + def _on_auto_toggled(self, on: bool) -> None: + self._plot._autoscale_enabled = on + if on: + self._plot._apply_auto_scale() + + # ---- periodic resync --------------------------------------------- + + def _sync_from_buffer(self) -> None: + buf = self._plot._buffer + if buf is None: + return + if self._spin_visible.value() != buf.n_visible: + self._spin_visible.blockSignals(True) + self._spin_visible.setRange(1, buf.n_channels) + self._spin_visible.setValue(buf.n_visible) + self._spin_visible.blockSignals(False) + if self._btn_auto.isChecked() != self._plot._autoscale_enabled: + self._btn_auto.blockSignals(True) + self._btn_auto.setChecked(self._plot._autoscale_enabled) + self._btn_auto.blockSignals(False) diff --git a/src/phosphor/sweep_buffer.py b/src/phosphor/sweep_buffer.py index 1f8e70c..2f79644 100644 --- a/src/phosphor/sweep_buffer.py +++ b/src/phosphor/sweep_buffer.py @@ -27,6 +27,8 @@ def __init__( n_columns: int, n_visible: int, max_events: int = DEFAULT_MAX_EVENTS, + channel_order: str = "top_down", + amplitude_scale: float = 1.0, ): self.n_channels = n_channels self.srate = srate @@ -35,6 +37,13 @@ def __init__( self.n_columns = n_columns self.n_visible = min(n_visible, n_channels) self.channel_offset = 0 + # ``top_down``: channel index 0 at the top of the plot, growing downward. + # ``bottom_up``: channel 0 at the bottom (legacy / scientific default). + self.channel_order = channel_order + # Multiplier applied to displayed sample values *after* the data-driven + # autoscale. >1 makes waves taller (may clip into adjacent rows); + # <1 makes them flatter. Channel row positions are not affected. + self._amplitude_scale = float(amplitude_scale) self._lock = threading.Lock() self._version = 0 @@ -70,6 +79,8 @@ def _allocate(self): self._dirty_start: int | None = None self._dirty_end: int | None = None self._events_dirty = True + # Per-channel midpoint cache (refreshed on every full rebuild). + self._ch_mid = np.zeros((self.n_visible, 1), dtype=np.float32) self._version += 1 @@ -211,6 +222,18 @@ def _compute_y_scale(self) -> float: max_abs = max(float(np.abs(self.display_mins).max()), float(np.abs(self.display_maxs).max())) return 0.5 / max(max_abs, 1e-12) + def _compute_ch_mid(self, scale: float) -> np.ndarray: + """Per-channel midpoint (post-scale) of the visible window's range. + + Subtracted before amplitude scaling so a channel zooms around its + own visual center instead of around y=0 — keeps DC bias from + translating channels as the user changes ``amplitude_scale``. + Must be called while holding ``_lock``. + """ + ch_min = self.display_mins.min(axis=0) + ch_max = self.display_maxs.max(axis=0) + return (((ch_min + ch_max) / 2) * scale).astype(np.float32).reshape(-1, 1) + def _build_multiline_array(self, mins, maxs, col_indices, scale) -> np.ndarray: """Build a ``[n_visible, 2*n_cols, 3]`` array from min/max slices. @@ -221,11 +244,52 @@ def _build_multiline_array(self, mins, maxs, col_indices, scale) -> np.ndarray: col_x = col_indices.astype(np.float32) / max(self.n_columns - 1, 1) * self.display_dur out[:, 0::2, 0] = col_x[np.newaxis, :] out[:, 1::2, 0] = col_x[np.newaxis, :] - out[:, 0::2, 1] = mins.T * scale - out[:, 1::2, 1] = maxs.T * scale - out[:, :, 2] = np.arange(self.n_visible)[:, np.newaxis] + # ``amplitude_scale`` lets the user scale the waveform alone — the + # channel row positions (Z below) are unaffected, so big-amplitude + # signals just clip into adjacent rows rather than rescaling the + # whole canvas. We zoom around each channel's own midpoint so that + # DC bias does not translate the line down/up as ``amp`` grows. + amp = self._amplitude_scale + ch_mid = self._ch_mid # (n_visible, 1), already in post-_y_scale units + out[:, 0::2, 1] = (mins.T * scale - ch_mid) * amp + ch_mid + out[:, 1::2, 1] = (maxs.T * scale - ch_mid) * amp + ch_mid + if self.channel_order == "top_down": + # Channel 0 at the highest Z (drawn at the top of the canvas); + # subsequent channels grow downward. + z_indices = (self.n_visible - 1) - np.arange(self.n_visible) + else: + z_indices = np.arange(self.n_visible) + out[:, :, 2] = z_indices[:, np.newaxis] return out + # ------------------------------------------------------------------ + # Display-state setters (thread-safe, mark dirty) + # ------------------------------------------------------------------ + + @property + def amplitude_scale(self) -> float: + return self._amplitude_scale + + def set_amplitude_scale(self, scale: float) -> None: + scale = max(float(scale), 1e-6) + with self._lock: + if scale == self._amplitude_scale: + return + self._amplitude_scale = scale + # Force a full rebuild on the next animation frame. + self._dirty_start = 0 + self._dirty_end = self.n_columns - 1 + + def set_channel_order(self, order: str) -> None: + if order not in ("top_down", "bottom_up"): + raise ValueError(f"channel_order must be 'top_down' or 'bottom_up', got {order!r}") + with self._lock: + if order == self.channel_order: + return + self.channel_order = order + self._dirty_start = 0 + self._dirty_end = self.n_columns - 1 + def get_multiline_data(self) -> np.ndarray: """Full data shaped ``[n_visible, 2*n_columns, 3]`` for fastplotlib MultiLineGraphic. @@ -234,6 +298,7 @@ def get_multiline_data(self) -> np.ndarray: """ with self._lock: self._y_scale = self._compute_y_scale() + self._ch_mid = self._compute_ch_mid(self._y_scale) out = self._build_multiline_array( self.display_mins, self.display_maxs, @@ -260,6 +325,7 @@ def get_dirty_multiline_range(self) -> tuple[np.ndarray, int, int] | None: if old_scale > 0 and abs(new_scale - old_scale) / old_scale > 0.2: # Full update with new scale self._y_scale = new_scale + self._ch_mid = self._compute_ch_mid(self._y_scale) out = self._build_multiline_array( self.display_mins, self.display_maxs, @@ -287,6 +353,7 @@ def get_dirty_multiline_range(self) -> tuple[np.ndarray, int, int] | None: else: # Wrapped — full update self._y_scale = new_scale + self._ch_mid = self._compute_ch_mid(self._y_scale) out = self._build_multiline_array( self.display_mins, self.display_maxs, diff --git a/src/phosphor/sweep_widget.py b/src/phosphor/sweep_widget.py index 3077b9e..e79f593 100644 --- a/src/phosphor/sweep_widget.py +++ b/src/phosphor/sweep_widget.py @@ -9,15 +9,16 @@ from .channel_plot import ChannelPlotWidget from .constants import ( - CHANNEL_COLORS, CURSOR_COLOR, CURSOR_GAP_COLUMNS, DEFAULT_DISPLAY_DUR, + DEFAULT_LINE_THICKNESS, DEFAULT_MAX_EVENTS, DEFAULT_N_COLUMNS, DEFAULT_N_VISIBLE, EVENT_POOL_SIZE, EVENT_THICKNESS, + SOFT_CHANNEL_COLORS, ) from .sweep_buffer import SweepBuffer, SweepEvent from .x_axis import XAxisWidget @@ -34,6 +35,17 @@ class SweepConfig: n_visible: int = DEFAULT_N_VISIBLE channel_labels: list[str] | None = None max_events: int = DEFAULT_MAX_EVENTS + # ``top_down`` (default) puts channel index 0 at the top of the canvas + # — what most scientific viewers do. ``bottom_up`` puts it at the bottom. + channel_order: str = "top_down" + # Per-channel waveform line thickness in fastplotlib units. + line_thickness: float = DEFAULT_LINE_THICKNESS + # Repeating per-channel color palette. ``None`` uses the soft default + # (less eye fatigue with many channels). Pass ``CHANNEL_COLORS`` from + # ``phosphor.constants`` for the legacy bright palette. + colors: list[tuple[float, float, float, float]] | None = None + # Initial waveform-amplitude multiplier (1.0 = data autoscale only). + amplitude_scale: float = 1.0 class SweepWidget(ChannelPlotWidget): @@ -67,8 +79,12 @@ def __init__(self, config: SweepConfig, parent: QWidget | None = None): n_columns=config.n_columns, n_visible=min(config.n_visible, config.n_channels), max_events=config.max_events, + channel_order=config.channel_order, + amplitude_scale=config.amplitude_scale, ) self._buffer = self.sweep_buffer + self._palette = list(config.colors) if config.colors is not None else list(SOFT_CHANNEL_COLORS) + self._line_thickness = config.line_thickness # Create initial graphics self._cached_version = -1 @@ -138,15 +154,15 @@ def _setup_graphics(self) -> None: buf = self.sweep_buffer data = buf.get_multiline_data() - # Build per-channel colors (cycling through CHANNEL_COLORS) + # Build per-channel colors (cycling through configured palette) n_vis = buf.n_visible - colors = [CHANNEL_COLORS[i % len(CHANNEL_COLORS)][:3] for i in range(n_vis)] + colors = [self._palette[i % len(self._palette)][:3] for i in range(n_vis)] self._multi_line = subplot.add_multi_line( data, colors=colors, z_offset_scale=self._z_offset_scale, - thickness=1.5, + thickness=self._line_thickness, ) # Cursor: vertical line at the sweep position.