diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8c8b948..2b76742 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,6 +17,7 @@ on: - '**/*.md' - '**/*.rst' - '**/*.txt' + - 'hyperspy_gui_traitsui/_external/*.py' schedule: # run once a week - cron: '16 13 * * 5' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1bdcdc0..ba59476 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,7 +43,6 @@ jobs: # correct version with setuptools_scm run: | git remote add upstream https://github.com/hyperspy/hyperspy_gui_traitsui.git - git fetch --prune --unshallow git fetch upstream --tags - uses: actions/setup-python@v5 @@ -76,12 +75,16 @@ jobs: - name: Install HyperSpy (RELEASE_next_major) if: contains( matrix.LABEL, 'RnM') run: | - pip install git+https://github.com/hyperspy/hyperspy.git@RELEASE_next_major + # pip install git+https://github.com/hyperspy/hyperspy.git@RELEASE_next_major + # revert back when hyperspy 2.1 is released + pip install git+https://github.com/hyperspy/hyperspy.git@RELEASE_next_minor - name: Install HyperSpy (RELEASE_next_patch) if: contains( matrix.LABEL, 'RnP') run: | - pip install git+https://github.com/hyperspy/hyperspy.git@RELEASE_next_patch + # pip install git+https://github.com/hyperspy/hyperspy.git@RELEASE_next_patch + # revert back when hyperspy 2.1 is released + pip install git+https://github.com/hyperspy/hyperspy.git@RELEASE_next_minor - name: Install exSpy if: ${{ ! contains( matrix.LABEL, 'minimum') }} diff --git a/CHANGES.md b/CHANGES.md index 03ba8df..b2e7d3f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,7 @@ ## v2.1 (UNRELEASED) +* Fix slider in image contrast editor on python >=3.10 ([#76](https://github.com/hyperspy/hyperspy_gui_traitsui/pull/76)). * Fix getting version with editable installation ([#75](https://github.com/hyperspy/hyperspy_gui_traitsui/pull/75)). * Add releasing guide and release script ([#75](https://github.com/hyperspy/hyperspy_gui_traitsui/pull/75)). * Fix regression with editable installation ([#74](https://github.com/hyperspy/hyperspy_gui_traitsui/pull/74)). diff --git a/hyperspy_gui_traitsui/_external/bounds_editor.py b/hyperspy_gui_traitsui/_external/bounds_editor.py new file mode 100644 index 0000000..e90ecff --- /dev/null +++ b/hyperspy_gui_traitsui/_external/bounds_editor.py @@ -0,0 +1,211 @@ +# (C) Copyright 2004-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +# Use upstream version when https://github.com/enthought/traitsui/pull/2048 +# is merged and release + +from pyface.qt import QtGui, QtCore + +from traits.api import Float, Any, Str, Union + +from traitsui.editors.api import RangeEditor +try: + from traitsui.qt.editor import Editor +except: + from traitsui.qt4.editor import Editor +from .range_slider import RangeSlider + + +class _BoundsEditor(Editor): + + evaluate = Any() + + min = Any() + max = Any() + low = Any() + high = Any() + format_str = Str() + + def init(self, parent): + """Finishes initializing the editor by creating the underlying toolkit + widget. + """ + factory = self.factory + if not factory.low_name: + self.low = factory.low + + if not factory.high_name: + self.high = factory.high + + self.max = factory.max + self.min = factory.min + + self.format_str = factory.format_str + + self.evaluate = factory.evaluate + self.sync_value(factory.evaluate_name, "evaluate", "from") + + self.sync_value(factory.low_name, "low", "both") + self.sync_value(factory.high_name, "high", "both") + + self.control = QtGui.QWidget() + panel = QtGui.QHBoxLayout(self.control) + panel.setContentsMargins(0, 0, 0, 0) + + self._label_lo = QtGui.QLineEdit(self.format_str % self.low) + self._label_lo.editingFinished.connect(self.update_low_on_enter) + panel.addWidget(self._label_lo) + + # The default size is a bit too big and probably doesn't need to grow. + sh = self._label_lo.sizeHint() + sh.setWidth(sh.width() // 2) + self._label_lo.setMaximumSize(sh) + + self.control.slider = slider = RangeSlider(QtCore.Qt.Orientation.Horizontal) + slider.setTracking(factory.auto_set) + slider.setMinimum(0) + slider.setMaximum(10000) + slider.setPageStep(1000) + slider.setSingleStep(100) + slider.setLow(self._convert_to_slider(self.low)) + slider.setHigh(self._convert_to_slider(self.high)) + + slider.sliderMoved.connect(self.update_object_on_scroll) + panel.addWidget(slider) + + self._label_hi = QtGui.QLineEdit(self.format_str % self.high) + self._label_hi.editingFinished.connect(self.update_high_on_enter) + panel.addWidget(self._label_hi) + + # The default size is a bit too big and probably doesn't need to grow. + sh = self._label_hi.sizeHint() + sh.setWidth(sh.width() // 2) + self._label_hi.setMaximumSize(sh) + + self.set_tooltip(slider) + self.set_tooltip(self._label_lo) + self.set_tooltip(self._label_hi) + + def update_low_on_enter(self): + try: + try: + low = eval(str(self._label_lo.text()).strip()) + if self.evaluate is not None: + low = self.evaluate(low) + except Exception as ex: + low = self.low + self._label_lo.setText(self.format_str % self.low) + + if not self.factory.is_float: + low = int(low) + + if low > self.high: + low = self.high - self._step_size() + self._label_lo.setText(self.format_str % low) + + self.control.slider.setLow(self._convert_to_slider(low)) + self.low = low + except: + pass + + def update_high_on_enter(self): + try: + try: + high = eval(str(self._label_hi.text()).strip()) + if self.evaluate is not None: + high = self.evaluate(high) + except: + high = self.high + self._label_hi.setText(self.format_str % self.high) + + if not self.factory.is_float: + high = int(high) + + if high < self.low: + high = self.low + self._step_size() + self._label_hi.setText(self.format_str % high) + + self.control.slider.setHigh(self._convert_to_slider(high)) + self.high = high + except: + pass + + def update_object_on_scroll(self, pos): + low = self._convert_from_slider(self.control.slider.low()) + high = self._convert_from_slider(self.control.slider.high()) + + if self.factory.is_float: + self.low = low + self.high = high + else: + self.low = int(low) + self.high = int(high) + + # update the sliders to the int values or the sliders + # will jiggle + self.control.slider.setLow(self._convert_to_slider(low)) + self.control.slider.setHigh(self._convert_to_slider(high)) + + def update_editor(self): + return + + def _check_max_and_min(self): + # check if max & min have been defined: + if self.max is None: + self.max = self.high + if self.min is None: + self.min = self.low + + def _step_size(self): + slider_delta = ( + self.control.slider.maximum() - self.control.slider.minimum() + ) + range_delta = self.max - self.min + + return float(range_delta) / slider_delta + + def _convert_from_slider(self, slider_val): + self._check_max_and_min() + return self.min + slider_val * self._step_size() + + def _convert_to_slider(self, value): + self._check_max_and_min() + return ( + self.control.slider.minimum() + + (value - self.min) / self._step_size() + ) + + def _low_changed(self, low): + if self.control is None: + return + if self._label_lo is not None: + self._label_lo.setText(self.format_str % low) + + self.control.slider.setLow(self._convert_to_slider(low)) + + def _high_changed(self, high): + if self.control is None: + return + if self._label_hi is not None: + self._label_hi.setText(self.format_str % high) + + self.control.slider.setHigh(self._convert_to_slider(self.high)) + + +class BoundsEditor(RangeEditor): + + min = Union(None, Float) + max = Union(None, Float) + + def _get_simple_editor_class(self): + return _BoundsEditor + + def _get_custom_editor_class(self): + return _BoundsEditor diff --git a/hyperspy_gui_traitsui/_external/range_slider.py b/hyperspy_gui_traitsui/_external/range_slider.py new file mode 100644 index 0000000..6f5224e --- /dev/null +++ b/hyperspy_gui_traitsui/_external/range_slider.py @@ -0,0 +1,216 @@ +# (C) Copyright 2004-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from pyface.qt import QtGui, QtCore + + +class RangeSlider(QtGui.QSlider): + """A slider for ranges. + + This class provides a dual-slider for ranges, where there is a defined + maximum and minimum, as is a normal slider, but instead of having a + single slider value, there are 2 slider values. + + This class emits the same signals as the QSlider base class, with the + exception of valueChanged + """ + + def __init__(self, *args): + super().__init__(*args) + + self._low = self.minimum() + self._high = self.maximum() + + self.pressed_control = QtGui.QStyle.SubControl.SC_None + self.hover_control = QtGui.QStyle.SubControl.SC_None + self.click_offset = 0 + + # 0 for the low, 1 for the high, -1 for both + self.active_slider = 0 + + def low(self): + return self._low + + def setLow(self, low): + self._low = low + self.update() + + def high(self): + return self._high + + def setHigh(self, high): + self._high = high + self.update() + + def paintEvent(self, event): + # based on + # http://qt.gitorious.org/qt/qt/blobs/master/src/gui/widgets/qslider.cpp + + painter = QtGui.QPainter(self) + style = QtGui.QApplication.style() + + for i, value in enumerate([self._low, self._high]): + opt = QtGui.QStyleOptionSlider() + self.initStyleOption(opt) + + # Only draw the groove for the first slider so it doesn't get drawn + # on top of the existing ones every time + if i == 0: + opt.subControls = ( + QtGui.QStyle.SC_SliderGroove | QtGui.QStyle.SC_SliderHandle + ) + else: + opt.subControls = QtGui.QStyle.SC_SliderHandle + + if self.tickPosition() != self.NoTicks: + opt.subControls |= QtGui.QStyle.SC_SliderTickmarks + + if self.pressed_control: + opt.activeSubControls = self.pressed_control + opt.state |= QtGui.QStyle.StateFlag.State_Sunken + else: + opt.activeSubControls = self.hover_control + + opt.sliderPosition = int(value) + opt.sliderValue = int(value) + style.drawComplexControl( + QtGui.QStyle.ComplexControl.CC_Slider, opt, painter, self + ) + + def mousePressEvent(self, event): + event.accept() + + style = QtGui.QApplication.style() + button = event.button() + + # In a normal slider control, when the user clicks on a point in the + # slider's total range, but not on the slider part of the control the + # control would jump the slider value to where the user clicked. + # For this control, clicks which are not direct hits will slide both + # slider parts + + if button: + opt = QtGui.QStyleOptionSlider() + self.initStyleOption(opt) + + self.active_slider = -1 + + for i, value in enumerate([self._low, self._high]): + opt.sliderPosition = int(value) + hit = style.hitTestComplexControl( + style.CC_Slider, opt, event.pos(), self + ) + if hit == style.SC_SliderHandle: + self.active_slider = i + self.pressed_control = hit + + self.triggerAction(self.SliderMove) + self.setRepeatAction(self.SliderNoAction) + self.setSliderDown(True) + break + + if self.active_slider < 0: + self.pressed_control = QtGui.QStyle.SC_SliderHandle + self.click_offset = self.__pixelPosToRangeValue( + self.__pick(event.pos()) + ) + self.triggerAction(self.SliderMove) + self.setRepeatAction(self.SliderNoAction) + else: + event.ignore() + + def mouseMoveEvent(self, event): + if self.pressed_control != QtGui.QStyle.SC_SliderHandle: + event.ignore() + return + + event.accept() + new_pos = int(self.__pixelPosToRangeValue(self.__pick(event.pos()))) + opt = QtGui.QStyleOptionSlider() + self.initStyleOption(opt) + + if self.active_slider < 0: + offset = new_pos - self.click_offset + self._high += offset + self._low += offset + if self._low < self.minimum(): + diff = self.minimum() - self._low + self._low += diff + self._high += diff + if self._high > self.maximum(): + diff = self.maximum() - self._high + self._low += diff + self._high += diff + elif self.active_slider == 0: + if new_pos >= self._high: + new_pos = self._high - 1 + self._low = new_pos + else: + if new_pos <= self._low: + new_pos = self._low + 1 + self._high = new_pos + + self.click_offset = new_pos + + self.update() + + self.sliderMoved.emit(new_pos) + + def __pick(self, pt): + if self.orientation() == QtCore.Qt.Orientation.Horizontal: + return pt.x() + else: + return pt.y() + + def __pixelPosToRangeValue(self, pos): + opt = QtGui.QStyleOptionSlider() + self.initStyleOption(opt) + style = QtGui.QApplication.style() + + gr = style.subControlRect( + style.CC_Slider, opt, style.SC_SliderGroove, self + ) + sr = style.subControlRect( + style.CC_Slider, opt, style.SC_SliderHandle, self + ) + + if self.orientation() == QtCore.Qt.Orientation.Horizontal: + slider_length = sr.width() + slider_min = gr.x() + slider_max = gr.right() - slider_length + 1 + else: + slider_length = sr.height() + slider_min = gr.y() + slider_max = gr.bottom() - slider_length + 1 + + return style.sliderValueFromPosition( + self.minimum(), + self.maximum(), + pos - slider_min, + slider_max - slider_min, + opt.upsideDown, + ) + + +if __name__ == "__main__": + import sys + + def echo(value): + print(value) + + app = QtGui.QApplication(sys.argv) + slider = RangeSlider() + slider.setMinimum(0) + slider.setMaximum(10000) + slider.setLow(0) + slider.setHigh(10000) + slider.sliderMoved.connect(echo) + slider.show() + app.exec_() diff --git a/hyperspy_gui_traitsui/tools.py b/hyperspy_gui_traitsui/tools.py index a0ce67a..b29fd4b 100644 --- a/hyperspy_gui_traitsui/tools.py +++ b/hyperspy_gui_traitsui/tools.py @@ -1,5 +1,3 @@ -from packaging.version import Version - import traitsui import traitsui.api as tu from traitsui.menu import OKButton, CancelButton, OKCancelButtons @@ -328,19 +326,7 @@ def load(obj, **kwargs): @add_display_arg def image_contrast_editor_traitsui(obj, **kwargs): - # In traitsui 8.0.0, traitsui.qt4 was changed to traitsui.qt - if Version(traitsui.__version__) >= Version('8.0.0'): - from traitsui.qt.extra.bounds_editor import BoundsEditor - else: - from traitsui.qt4.extra.bounds_editor import BoundsEditor - - # format has been deprecated in Release 7.3.0, replaced by format_str - # https://github.com/enthought/traitsui/pull/1684 - # Remove and simplify when minimum traitsui version is 7.3.0 - FORMAT_STR = 'format' if Version(traitsui.__version__) < Version('7.0.0') \ - else 'format_str' - def get_format_dict(formatting): - return {FORMAT_STR:formatting} + from hyperspy_gui_traitsui._external.bounds_editor import BoundsEditor view = tu.View( tu.Group( @@ -363,12 +349,12 @@ def get_format_dict(formatting): label='Auto', show_label=True, ), - tu.Item('vmin_percentile', + tu.Item('percentile_range', label='vmin/vmax percentile', editor=BoundsEditor( low_name='vmin_percentile', high_name='vmax_percentile', - **get_format_dict('%.2f'), + format_str='%.2f', )), show_border=True, ), @@ -388,7 +374,7 @@ def get_format_dict(formatting): editor=tu.RangeEditor(low=0.1, high=3., mode="slider", - **get_format_dict('%.2f'), + format_str='%.2f', ), ), tu.Item('linthresh', @@ -398,7 +384,7 @@ def get_format_dict(formatting): editor=tu.RangeEditor(low=0.01, high=1., mode="slider", - **get_format_dict('%.2f'), + format_str='%.2f', ), ), tu.Item('linscale', @@ -408,7 +394,7 @@ def get_format_dict(formatting): editor=tu.RangeEditor(low=0., high=10., mode="slider", - **get_format_dict('%.2f'), + format_str='%.2f', ), ), show_border=True, diff --git a/pyproject.toml b/pyproject.toml index 05ea35a..99a35d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,10 +32,10 @@ classifiers = [ "Operating System :: MacOS", ] dependencies = [ - "hyperspy>=2.0rc0", + "hyperspy>=2.1.0.dev119", "link_traits", "traits>=5.0", - "traitsui>=6.1,!=8.0.0", + "traitsui>=7.3", ] dynamic = ["version"]