Skip to content

cellects.gui.custom_widgets

cellects.gui.custom_widgets

This module contains all modified/simplified widgets from PySide6 It is made to be easier to use and to be consistant in terms of colors and sizes.

InsertImage

Bases: QLabel

Source code in src/cellects/gui/custom_widgets.py
class InsertImage(QtWidgets.QLabel):
    def __init__(self, parent=None, track_mouse: bool=False):
        super().__init__()
        self.parent = parent
        self.image = None
        self.panning = False
        self.last_mouse = None
        self.track_mouse = track_mouse
        self.closed = False
        self.zoom = 1.0
        self.base_scale = 1.0
        self.pan_x = 0
        self.pan_y = 0
        self.pixmap = None
        self.setMinimumWidth(200)
        self.setMinimumHeight(200)

        self.bar_size = 16
        self.vbar = QtWidgets.QScrollBar(QtCore.Qt.Vertical, self)
        self.hbar = QtWidgets.QScrollBar(QtCore.Qt.Horizontal, self)

        self.vbar.setFixedWidth(self.bar_size)
        self.hbar.setFixedHeight(self.bar_size)

        self.vbar.valueChanged.connect(self._vbar_changed)
        self.hbar.valueChanged.connect(self._hbar_changed)

        self._layout_bars()
        self._sync_scrollbars()


        self.timer = QtCore.QTimer(self)
        self.timer.setSingleShot(True)
        self.timer.timeout.connect(self._update_scaled)

        self.setSizePolicy(
            QtWidgets.QSizePolicy.Policy.Expanding,
            QtWidgets.QSizePolicy.Policy.Expanding
        )

    def update_image(self, image: NDArray[np.uint8]):
        dims = image.shape
        if not isinstance(image, np.uint8):
            image = image.astype(np.uint8)
        img_max_int = image.max()
        if img_max_int < 10:
            image *= 255 // img_max_int
        if len(image.shape) == 3:
            image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
            img_format = QImage.Format_RGB888
        else:
            img_format = QImage.Format_Grayscale8
        self.image = QImage(image.data, dims[1], dims[0], image.strides[0], img_format)
        self.pixmap = QPixmap.fromImage(self.image)
        self._schedule_update()

    def update_screen_limits(self):
        screen = self.screen()
        if not screen:
            return

        geom = screen.availableGeometry()

        # Use a fraction of screen size for image cap
        self.max_size = (int(geom.width() * 0.8), int(geom.height() * 0.8))
        self._schedule_update()

    def _has_image(self):
        return self.image is not None and not self.image.isNull()

    def _view_size(self):
        """
        Visible image area, excluding the scrollbar strips.
        """
        w = max(0, self.width() - self.bar_size)
        h = max(0, self.height() - self.bar_size)
        return w, h

    def _layout_bars(self):
        w = max(0, self.width())
        h = max(0, self.height())
        b = self.bar_size

        self.vbar.setGeometry(max(0, w - b), 0, b, max(0, h - b))
        self.hbar.setGeometry(0, max(0, h - b), max(0, w - b), b)

    def _clamp_pan(self):
        """
        Keep pan_x/pan_y within a useful range.

        If the displayed image is smaller than the view, center it.
        If it is larger, allow panning exactly far enough to see the edges.
        """
        if not self._has_image():
            self.pan_x = 0
            self.pan_y = 0
            return

        scale = self.base_scale * self.zoom
        if scale <= 0:
            self.pan_x = 0
            self.pan_y = 0
            return

        vw, vh = self._view_size()

        dw = self.image.width() * scale
        dh = self.image.height() * scale

        if dw > vw:
            half = max(0.0, (dw - vw) / 2.0)
            self.pan_x = float(np.clip(self.pan_x, -half, half))
        else:
            self.pan_x = 0

        if dh > vh:
            half = max(0.0, (dh - vh) / 2.0)
            self.pan_y = float(np.clip(self.pan_y, -half, half))
        else:
            self.pan_y = 0

    def _sync_scrollbars(self):
        """
        Update scrollbar range/value from current zoom and pan.
        """
        if not self._has_image():
            self.hbar.setRange(0, 0)
            self.hbar.setValue(0)
            self.vbar.setRange(0, 0)
            self.vbar.setValue(0)

            self.hbar.setEnabled(False)
            self.vbar.setEnabled(False)
            return

        scale = self.base_scale * self.zoom
        vw, vh = self._view_size()

        dw = self.image.width() * scale
        dh = self.image.height() * scale

        max_x = max(0, int(dw - vw))
        max_y = max(0, int(dh - vh))

        self.hbar.setRange(0, max_x)
        self.vbar.setRange(0, max_y)

        self.hbar.setSingleStep(max(1, int(dw * 0.02)))
        self.vbar.setSingleStep(max(1, int(dh * 0.02)))

        self.hbar.setPageStep(max(1, int(vw)))
        self.vbar.setPageStep(max(1, int(vh)))

        self.hbar.setEnabled(max_x > 0)
        self.vbar.setEnabled(max_y > 0)

        self.hbar.blockSignals(True)
        self.vbar.blockSignals(True)

        if max_x > 0:
            value_x = (dw - vw) / 2.0 - self.pan_x
            self.hbar.setValue(int(np.clip(value_x, 0, max_x)))
        else:
            self.hbar.setValue(0)

        if max_y > 0:
            value_y = (dh - vh) / 2.0 - self.pan_y
            self.vbar.setValue(int(np.clip(value_y, 0, max_y)))
        else:
            self.vbar.setValue(0)

        self.hbar.blockSignals(False)
        self.vbar.blockSignals(False)

    def _hbar_changed(self, value):
        if not self._has_image():
            return

        scale = self.base_scale * self.zoom
        vw, vh = self._view_size()

        dw = self.image.width() * scale
        max_x = max(0, int(dw - vw))

        if max_x > 0:
            self.pan_x = (dw - vw) / 2.0 - value
        else:
            self.pan_x = 0

        self._clamp_pan()
        self.update()

    def _vbar_changed(self, value):
        if not self._has_image():
            return

        scale = self.base_scale * self.zoom
        vw, vh = self._view_size()

        dh = self.image.height() * scale
        max_y = max(0, int(dh - vh))

        if max_y > 0:
            self.pan_y = (dh - vh) / 2.0 - value
        else:
            self.pan_y = 0

        self._clamp_pan()
        self.update()

    def _schedule_update(self):
        self.timer.start(20)

    def resizeEvent(self, event):
        super().resizeEvent(event)
        self._layout_bars()
        self._schedule_update()

    def _update_scaled(self):
        if not self._has_image():
            return

        vw, vh = self._view_size()

        if vw <= 0 or vh <= 0:
            return

        sx = self.width() / self.image.width()
        sy = self.height() / self.image.height()
        self.base_scale = min(sx, sy)
        self._sync_scrollbars()
        self.update()

    def paintEvent(self, event):
        if not self._has_image():
            return

        painter = QPainter(self)
        painter.fillRect(self.rect(), Qt.GlobalColor.black)

        painter.setRenderHint(
            QPainter.RenderHint.SmoothPixmapTransform,
            True
        )

        scale = self.base_scale * self.zoom
        if scale <= 0:
            return
        draw_w = self.image.width() * scale
        draw_h = self.image.height() * scale

        vw, vh = self._view_size()

        x = (vw - draw_w) / 2
        y = (vh - draw_h) / 2
        painter.translate(x + self.pan_x, y + self.pan_y)
        painter.scale(scale, scale)
        if self.pixmap is not None and not self.pixmap.isNull():
            painter.drawPixmap(0, 0, self.pixmap)

    def showEvent(self, event):
        super().showEvent(event)
        self._layout_bars()
        self.update_screen_limits()

    def image_coordinates(self, pos):
        if not self._has_image():
            return None

        if self.image.width() <= 0 or self.image.height() <= 0:
            return None

        scale = self.base_scale * self.zoom
        if scale <= 0:
            return None

        vw, vh = self._view_size()

        x0 = vw / 2 + self.pan_x - (self.image.width() * scale) / 2
        y0 = vh / 2 + self.pan_y - (self.image.height() * scale) / 2

        ix = (pos.x() - x0) / scale
        iy = (pos.y() - y0) / scale

        ix = int(np.clip(ix, 0, self.image.width() - 1))
        iy = int(np.clip(iy, 0, self.image.height() - 1))

        return iy, ix

    def mousePressEvent(self, event):
        if event.button() == QtCore.Qt.MiddleButton:
            self.panning = True
            self.last_mouse = event.position()
        elif self.track_mouse:
            self.parent.mouse_clicks(self, event)

    def mouseMoveEvent(self, event):
        if self.panning:
            delta = event.position() - self.last_mouse
            self.pan_x += delta.x()
            self.pan_y += delta.y()
            self.last_mouse = event.position()

            self._clamp_pan()
            self._sync_scrollbars()

            self.update()
        elif self.track_mouse:
            self.parent.mouse_moves(self, event)

    def mouseReleaseEvent(self, event):
        if event.button() == QtCore.Qt.MiddleButton:
            self.panning = False
        elif self.track_mouse:
            self.parent.mouse_releases(self, event)

    def wheelEvent(self, event):
        if not self._has_image():
            return

        # If pinch is reported as Ctrl + wheel, use that for zoom.
        # Normal wheel is treated as two-finger parallel scrolling/panning.
        if event.modifiers() & QtCore.Qt.ControlModifier:
            self._wheel_zoom(event)
        else:
            self._wheel_pan(event)

    def _wheel_zoom(self, event):
        dy = event.angleDelta().y()
        if dy == 0:
            return

        old_zoom = self.zoom
        factor = 1.15 if dy > 0 else 1 / 1.15

        self.zoom = float(np.clip(self.zoom * factor, 0.05, 50.0))

        mouse = event.position()
        vw, vh = self._view_size()

        scale_old = self.base_scale * old_zoom
        scale_new = self.base_scale * self.zoom

        if scale_old <= 0 or scale_new <= 0:
            self._sync_scrollbars()
            self.update()
            return

        ix = (mouse.x() - vw / 2 - self.pan_x) / scale_old
        iy = (mouse.y() - vh / 2 - self.pan_y) / scale_old

        self.pan_x = mouse.x() - vw / 2 - ix * scale_new
        self.pan_y = mouse.y() - vh / 2 - iy * scale_new

        self._clamp_pan()
        self._sync_scrollbars()
        self.update()

    def _wheel_pan(self, event):
        # Tune these factors if the pad scrolling feels too fast/slow.
        self.pan_x -= event.angleDelta().x() * 0.25
        self.pan_y -= event.angleDelta().y() * 0.25

        self._clamp_pan()
        self._sync_scrollbars()
        self.update()

    def closeEvent(self, event):
        self.closed = True
        super().closeEvent(event)

MainTabsWidget

Bases: QPushButton

A custom QPushButton that mimics an explorer tab appearance.

Features: - Customizable text - Night mode support - Three states: not_in_use (grey border), in_use (black border), not_usable (grey text) - Tooltip support for not_usable state

Source code in src/cellects/gui/custom_widgets.py
class MainTabsWidget(QtWidgets.QPushButton):
    """
    A custom QPushButton that mimics an explorer tab appearance.

    Features:
    - Customizable text
    - Night mode support
    - Three states: not_in_use (grey border), in_use (black border), not_usable (grey text)
    - Tooltip support for not_usable state
    """

    def __init__(self, text="", night_mode=False, parent=None):
        super().__init__()

        self.setText(text)
        self.state = "not_in_use"  # States: "not_in_use", "in_use", "not_usable"
        self.setFont(buttonfont)

        # Set basic properties
        self.setSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed)
        self.setFixedHeight(35)
        self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)

        self.night_mode_switch(night_mode)


    def night_mode_switch(self, night_mode):
        self._night_mode = night_mode
        self.update_style()

    def update_style(self):
        """Update the widget's stylesheetµ"""

        if self.state == "not_usable":
            tab_text_color = "#888888"
            self.setCursor(QtCore.Qt.CursorShape.ForbiddenCursor)
        else:
            if self._night_mode:
                tab_text_color = night_text_Color
            else:
                tab_text_color = textColor
            if self.state == "not_in_use":
                self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
        if self.state == "in_use":
            border_width = 2
            if self._night_mode:
                tab_border = f"{border_width}px solid #adadad"
            else:
                tab_border = f"{border_width}px solid #323241"
        else:
            border_width = 1
            if self._night_mode:
                tab_border = f"{border_width}px solid #323241"
            else:
                tab_border = f"{border_width}px solid #adadad"

        if self._night_mode:
            style = {"buttoncolor": night_button_color, "buttontextColor": tab_text_color, "border": tab_border,
                     "font_family": tabfont, "font_size": "22pt", #"font_weight": "bold",
                     "border-top-color": "#323241", "border-right-color": "#323241", # invisible
                     "border-top-left-radius": "10", "border-bottom-left-radius": "1"}
        else:
            style = {"buttoncolor": buttoncolor, "buttontextColor": tab_text_color, "border": tab_border,
                     "font_family": tabfont, "font_size": "22pt", #"font_weight": "bold",
                     "border-top-color": "#ffffff", "border-right-color": "#ffffff", # invisible
                     "border-top-left-radius": "10", "border-bottom-left-radius": "1"
                     }
        self.setStyleSheet(
            "background-color: %s; color: %s; border: %s; font-family: %s; font-size: %s;  border-top-color: %s; border-right-color: %s; border-top-left-radius: %s; border-bottom-left-radius: %s" % tuple(style.values()))


    def set_in_use(self):
        """Set the tab to 'in_use' state with black border."""
        self.state = "in_use"
        self.setToolTip("")  # Clear any tooltip
        self.update_style()

    def set_not_in_use(self):
        """Set the tab to 'not_in_use' state with grey border."""
        self.state = "not_in_use"
        self.setToolTip("")  # Clear any tooltip
        self.update_style()

    def set_not_usable(self, tooltip_text="This tab is not usable"):
        """
        Set the tab to 'not_usable' state with grey text.

        Args:
            tooltip_text (str): Custom tooltip text to show when hovering
        """
        self.state = "not_usable"
        self.setToolTip(tooltip_text)
        self.update_style()

    def get_state(self):
        """Get the current state of the tab."""
        return self.state

    def is_night_mode(self):
        """Check if night mode is enabled."""
        return self._night_mode

get_state()

Get the current state of the tab.

Source code in src/cellects/gui/custom_widgets.py
def get_state(self):
    """Get the current state of the tab."""
    return self.state

is_night_mode()

Check if night mode is enabled.

Source code in src/cellects/gui/custom_widgets.py
def is_night_mode(self):
    """Check if night mode is enabled."""
    return self._night_mode

set_in_use()

Set the tab to 'in_use' state with black border.

Source code in src/cellects/gui/custom_widgets.py
def set_in_use(self):
    """Set the tab to 'in_use' state with black border."""
    self.state = "in_use"
    self.setToolTip("")  # Clear any tooltip
    self.update_style()

set_not_in_use()

Set the tab to 'not_in_use' state with grey border.

Source code in src/cellects/gui/custom_widgets.py
def set_not_in_use(self):
    """Set the tab to 'not_in_use' state with grey border."""
    self.state = "not_in_use"
    self.setToolTip("")  # Clear any tooltip
    self.update_style()

set_not_usable(tooltip_text='This tab is not usable')

Set the tab to 'not_usable' state with grey text.

Args: tooltip_text (str): Custom tooltip text to show when hovering

Source code in src/cellects/gui/custom_widgets.py
def set_not_usable(self, tooltip_text="This tab is not usable"):
    """
    Set the tab to 'not_usable' state with grey text.

    Args:
        tooltip_text (str): Custom tooltip text to show when hovering
    """
    self.state = "not_usable"
    self.setToolTip(tooltip_text)
    self.update_style()

update_style()

Update the widget's stylesheetµ

Source code in src/cellects/gui/custom_widgets.py
def update_style(self):
    """Update the widget's stylesheetµ"""

    if self.state == "not_usable":
        tab_text_color = "#888888"
        self.setCursor(QtCore.Qt.CursorShape.ForbiddenCursor)
    else:
        if self._night_mode:
            tab_text_color = night_text_Color
        else:
            tab_text_color = textColor
        if self.state == "not_in_use":
            self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
    if self.state == "in_use":
        border_width = 2
        if self._night_mode:
            tab_border = f"{border_width}px solid #adadad"
        else:
            tab_border = f"{border_width}px solid #323241"
    else:
        border_width = 1
        if self._night_mode:
            tab_border = f"{border_width}px solid #323241"
        else:
            tab_border = f"{border_width}px solid #adadad"

    if self._night_mode:
        style = {"buttoncolor": night_button_color, "buttontextColor": tab_text_color, "border": tab_border,
                 "font_family": tabfont, "font_size": "22pt", #"font_weight": "bold",
                 "border-top-color": "#323241", "border-right-color": "#323241", # invisible
                 "border-top-left-radius": "10", "border-bottom-left-radius": "1"}
    else:
        style = {"buttoncolor": buttoncolor, "buttontextColor": tab_text_color, "border": tab_border,
                 "font_family": tabfont, "font_size": "22pt", #"font_weight": "bold",
                 "border-top-color": "#ffffff", "border-right-color": "#ffffff", # invisible
                 "border-top-left-radius": "10", "border-bottom-left-radius": "1"
                 }
    self.setStyleSheet(
        "background-color: %s; color: %s; border: %s; font-family: %s; font-size: %s;  border-top-color: %s; border-right-color: %s; border-top-left-radius: %s; border-bottom-left-radius: %s" % tuple(style.values()))

PButton

Bases: QPushButton

Source code in src/cellects/gui/custom_widgets.py
class PButton(QtWidgets.QPushButton):
    def __init__(self, text, fade=True, tip=None, night_mode=False):
        """

        self.setStyleSheet("background-color: rgb(107, 145, 202);\n"
                                "border-color: rgb(255, 255, 255);\n"
                                "color: rgb(0, 0, 0);\n"
                                "font: 17pt \"Britannic Bold\";")
        :param text:
        """
        super().__init__()
        self.setText(text)
        self.setToolTip(tip)
        self.night_mode_switch(night_mode)
        self.setFont(buttonfont)
        self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
        self.setFixedWidth(len(text)*15 + 25)
        self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)

    def night_mode_switch(self, night_mode):
        if night_mode:
            self.style = {"buttoncolor": night_button_color, "buttontextColor": night_text_Color, "buttonborder": night_button_border,
                          "buttonangles": buttonangles}
        else:
            self.style = {"buttoncolor": buttoncolor, "buttontextColor": textColor, "buttonborder": buttonborder,
                          "buttonangles": buttonangles}
        self.update_style()

    def event_filter(self, event):
        if event.type() == QtCore.QEvent.MouseMove:
            if event.buttons() == QtCore.Qt.NoButton:
                self.fade()
            else:
                self.unfade()

    def update_style(self):
        self.setStyleSheet(
            "background-color: %s; color: %s; border: %s; border-radius: %s" % tuple(self.style.values()))

    def color(self, color):
        self.style["buttoncolor"] = color
        self.update_style()

    def textcolor(self, textcolor):
        self.style["buttontextColor"] = textcolor
        self.update_style()

    def border(self, border):
        self.style["buttonborder"] = border
        self.update_style()

    def angles(self, angles):
        self.style["buttonangles"] = angles
        self.update_style()

    def fade(self):
        self.setWindowOpacity(0.5)
        self.setStyleSheet("background-color: %s; color: %s; border: %s; border-radius: %s" % (buttonclickedcolor, textColor, buttonborder, buttonangles))
        QtCore.QTimer.singleShot(300, self.unfade)

    def unfade(self):
        self.setWindowOpacity(1)
        self.setStyleSheet("background-color: %s; color: %s; border: %s; border-radius: %s" % (buttoncolor, textColor, buttonborder, buttonangles))

__init__(text, fade=True, tip=None, night_mode=False)

    self.setStyleSheet("background-color: rgb(107, 145, 202);

" "border-color: rgb(255, 255, 255); " "color: rgb(0, 0, 0); " "font: 17pt "Britannic Bold";") :param text:

Source code in src/cellects/gui/custom_widgets.py
def __init__(self, text, fade=True, tip=None, night_mode=False):
    """

    self.setStyleSheet("background-color: rgb(107, 145, 202);\n"
                            "border-color: rgb(255, 255, 255);\n"
                            "color: rgb(0, 0, 0);\n"
                            "font: 17pt \"Britannic Bold\";")
    :param text:
    """
    super().__init__()
    self.setText(text)
    self.setToolTip(tip)
    self.night_mode_switch(night_mode)
    self.setFont(buttonfont)
    self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
    self.setFixedWidth(len(text)*15 + 25)
    self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)

show(img)

Display a 2D image using Matplotlib.

Parameters:

Name Type Description Default
img

NumPy array

required

Returns:

Type Description
fig

The Qlabel containing the image.

Source code in src/cellects/gui/custom_widgets.py
def show(img):
    """
    Display a 2D image using Matplotlib.

    Parameters
    ----------
    img
        NumPy array

    Returns
    -------
    fig
        The Qlabel containing the image.
    """
    gui = InsertImage()
    gui.update_image(img)
    gui.show()
    return gui