Skip to content
  • Categories
  • Recent
  • Tags
  • Popular
  • Users
  • Groups
  • Search
  • Get Qt Extensions
  • Unsolved
Collapse
Brand Logo
  1. Home
  2. Qt Development
  3. Qt for Python
  4. Making a word processor ruler with Qt
Forum Updated to NodeBB v4.3 + New Features

Making a word processor ruler with Qt

Scheduled Pinned Locked Moved Unsolved Qt for Python
2 Posts 1 Posters 564 Views 1 Watching
  • Oldest to Newest
  • Newest to Oldest
  • Most Votes
Reply
  • Reply as topic
Log in to reply
This topic has been deleted. Only users with topic management privileges can see it.
  • D Offline
    D Offline
    duyjuwumu
    wrote on 26 Jan 2022, 01:51 last edited by duyjuwumu
    #1

    I'd want to make something like below with Qt.
    d39d94a6-4f94-4014-aed6-a0736b6c344d-image.png
    It looks like someone else tried and failed to do this in 2017. I was able to get a prototype working using three QSliders. But now I'm stuck on how to make it look like an actual word processing ruler.

    I don't want to use styles sheets because of the existing bugs that make ticks disappear[bug-1][bug-2]. My hope was to layer the three QSliders on top each other so that their three handles moved along the same groove. However, after reading the layout management docs and the basic layouts example, it seems like all pre-built layouts won't allow widgets to be directly on top of each and visible at the same time. I tried using setGeometry to manually position the QSliders, but handling the position and resizing was tedious and I'm sure there's a better solution.

    For a different application, people recommended reimplementing paintEvent to draw the the extra handles and to reimplement mouse events accordingly. However, if I use three QSliders all the signals will be emitted without handwriting code.

    Can anyone recommend a solution on how to make a word processing ruler in Qt? Below is the code I have so far. Note: it's written in Python, but feel free to share you suggestions in C++.

    7c0c32aa-2c50-4aff-8043-ae137a3ada09-image.png

    import sys
    from PySide6.QtCore import Qt, Slot
    from PySide6.QtWidgets import QApplication, QSlider, QTextEdit, QVBoxLayout
    
    
    class TextEdit(QTextEdit):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            text_indent_slider = QSlider(Qt.Horizontal)
            margin_left_slider = QSlider(Qt.Horizontal)
            margin_right_slider = QSlider(Qt.Horizontal)
            margin_right_slider.setInvertedAppearance(True)
            text_indent_slider.valueChanged.connect(self.set_text_indent)
            margin_left_slider.valueChanged.connect(self.set_margin_left)
            margin_right_slider.valueChanged.connect(self.set_margin_right)
            layout = QVBoxLayout()
            layout.addWidget(text_indent_slider)
            layout.addWidget(margin_left_slider)
            layout.addWidget(margin_right_slider)
    
            self.text_edit = QTextEdit(self)
            layout.addWidget(self.text_edit)
            self.setLayout(layout)
    
        @Slot(int)
        def set_text_indent(self, indent: int) -> None:
            text_cursor = self.text_edit.textCursor()
            block_format = text_cursor.blockFormat()
            block_format.setTextIndent(indent)
            text_cursor.mergeBlockFormat(block_format)
            self.text_edit.setTextCursor(text_cursor)
    
        @Slot(int)
        def set_margin_left(self, margin: int) -> None:
            text_cursor = self.text_edit.textCursor()
            block_format = text_cursor.blockFormat()
            block_format.setLeftMargin(margin)
            text_cursor.mergeBlockFormat(block_format)
            self.text_edit.setTextCursor(text_cursor)
    
        @Slot(int)
        def set_margin_right(self, margin: int) -> None:
            text_cursor = self.text_edit.textCursor()
            block_format = text_cursor.blockFormat()
            block_format.setRightMargin(margin)
            text_cursor.mergeBlockFormat(block_format)
            self.text_edit.setTextCursor(text_cursor)
    
    
    if __name__ == "__main__":
        app = QApplication([])
        mw = TextEdit()
        mw.show()
        sys.exit(app.exec())
    
    
    1 Reply Last reply
    0
    • D Offline
      D Offline
      duyjuwumu
      wrote on 31 Jan 2022, 01:20 last edited by
      #2

      Progress!... but still not completely solved. See below for the help needed
      5204ceb7-5449-4ba0-bb38-308f3f1754bd-image.png
      The ruler above is actually two QSliders. The top slider has one handle and changes the text indent property and the bottom slider is a python port of ctkRangeSlider with some modifications.

      This was my plan of attack:

      • Wrote set_values methods for the range slider controlling the left and right margin. The Top QSlider uses the built-in setValue

      • Overwrite paintEvent using QStyleOptionSlider and QStylePainter. Specifically, QStylePainter.drawComplexControl to draw QStyle.SC_SliderHandle.

      • Overwrite mousePressEvent, mouseMoveEvent, and mousePressEvent. I'm keeping track of the location of each handle and which one is currently pressed with enums and instance variables. I ported handleAtPos method and pixelPosToRangeValue and pixelPosFromRangeValue methods from ctkRangeSlider to do this.

      Where I still need help.

      There seems to be some rounding error when connecting the value of the QSlider with the margin and text indent values of QTextEdit. The larger the slider value, the more the text is offset from the slider handle.
      text_indent_offset.gif

      I'm guessing there's something wrong with my conversion, but I can't figure out what's wrong. Here's a minimal example.

      import sys
      from PySide6.QtCore import Qt, Slot
      from PySide6.QtWidgets import QApplication, QSlider, QTextEdit, QVBoxLayout, QWidget
      
      
      class TextEdit(QWidget):
          """The Main window"""
      
          def __init__(self, *args, **kwargs) -> None:
              super().__init__(*args, **kwargs)
              self.slider = QSlider(Qt.Horizontal)
              self.slider.valueChanged.connect(self.set_text_indent)
              layout = QVBoxLayout()
              layout.addWidget(self.slider)
      
              self.text_edit = QTextEdit()
              layout.addWidget(self.text_edit)
              self.setLayout(layout)
      
          @Slot(int)
          def set_text_indent(self, value: int) -> None:
              value_per_pixel = self.slider.maximum() / self.width()
              indent_in_pixels = value / value_per_pixel
              block_format = self.text_edit.textCursor().blockFormat()
              block_format.setTextIndent(indent_in_pixels)
              text_cursor = self.text_edit.textCursor()
              text_cursor.mergeBlockFormat(block_format)
              self.text_edit.setTextCursor(text_cursor)
      
      
      if __name__ == "__main__":
          app = QApplication([])
          mw = TextEdit()
          mw.show()
          sys.exit(app.exec())
      
      

      Do you see the error?

      Also since this seems like a problem other people have been trying to solve, here's the code for the first image.

      """Ruler for indents and margins."""
      import sys
      import typing
      import warnings
      from enum import Flag, auto
      
      from PySide6.QtCore import QFlag, QPoint, QRect, Qt, Signal, Slot
      from PySide6.QtGui import QFontMetricsF
      from PySide6.QtWidgets import (
          QAbstractSlider,
          QApplication,
          QSlider,
          QStyle,
          QStyleOptionSlider,
          QStylePainter,
          QTextEdit,
          QVBoxLayout,
          QWidget,
      )
      
      if typing.TYPE_CHECKING:
          from PySide6.QtGui import QMouseEvent, QPaintEvent, QTextBlockFormat
      
      
      class NewOptionSliderMixin:
          """Provide uniform way to generate new QStyleOptionSlider."""
      
          def _init_new_style_option_slider(self) -> QStyleOptionSlider:
              option = QStyleOptionSlider(1)
              self.initStyleOption(option)
              return option
      
      
      class RelativeSlider(NewOptionSliderMixin, QSlider):
          """A slider that respects the delta between its value and an external value."""
      
          delta_changed = Signal(int)
          minimum_changed = Signal(int)
          maximum_changed = Signal(int)
          _external_value_changed = Signal(int)
      
          def __init__(self, *args, **kwargs):
              super().__init__(*args, **kwargs)
              self._delta = 0
              self._external_value = 0
              self.valueChanged.connect(self._set_relative_value)
              self._external_value_changed.connect(self._preserve_delta)
      
          @Slot(int)
          def set_external_value(self, value: int) -> None:
              """Store eternal value."""
              self._external_value = value
              self._external_value_changed.emit(self._external_value)
      
          @Slot(int)
          def _set_relative_value(self, value: int) -> None:
              self._delta = value - self._external_value
              self.delta_changed.emit(self._delta)
      
          @Slot(int)
          def _preserve_delta(self, value: int) -> None:
              self.setValue(self._delta + value)
      
          def paintEvent(self, event: "QPaintEvent") -> None:
              """Only draw handle and tick labels."""
              painter = QStylePainter(self)
              option = self._init_new_style_option_slider()
              option.subControls = QStyle.SC_SliderHandle
              if self.tickPosition() != QSlider.NoTicks:
                  option.subControls |= QStyle.SC_SliderTickmarks
              painter.drawComplexControl(QStyle.CC_Slider, option)
      
              painter.setPen(Qt.black)
              font = self.font()
              font.setPixelSize(self.height() * 0.75)
              painter.setFont(font)
      
              pixels_per_inch = self.screen().physicalDotsPerInchX() * self.devicePixelRatio()
              label_x = pixels_per_inch
              label_num = 0
              tick_text = "|"
              font_metrics = QFontMetricsF(font)
              font_metrics.boundingRect(tick_text)
              tick_width = font_metrics.lineWidth()
      
              tick_x = pixels_per_inch / self.tickInterval()
              tick_num = 0
              y = self.rect().bottom()
              while tick_x < self.width():
                  if int(tick_x) == int(label_x):
                      label_num += 1
                      painter.drawText(QPoint(label_x - (tick_width / 2), y), str(label_num))
                      label_x += pixels_per_inch
                  else:
                      painter.drawText(QPoint(tick_x, y), tick_text)
                  tick_x += pixels_per_inch / self.tickInterval()
                  tick_num += 1
      
          def _pixel_pos_from_range_value(self, value: int) -> int:
              """https://github.com/mcallegari/qlcplus/blob/master/ui/src/ctkrangeslider.cpp."""
              option = self._init_new_style_option_slider()
              groove_rect = self.style().subControlRect(QStyle.CC_Slider, option, QStyle.SC_SliderGroove)
              handle_rect = self.style().subControlRect(QStyle.CC_Slider, option, QStyle.SC_SliderHandle)
              slider_length = handle_rect.width()
              slider_min = groove_rect.x()
              slider_max = groove_rect.right() - slider_length + 1
              upside_down = option.upsideDown
              span = slider_max - slider_min
              min_val, max_val = self.minimum(), self.maximum()
              slider_pos = QStyle.sliderPositionFromValue(min_val, max_val, value, span, upside_down)
              return slider_pos + slider_min
      
          def sliderChange(self, change: QAbstractSlider.SliderChange) -> None:
              """Emit minimum and maximum changed."""
              if change == QAbstractSlider.SliderRangeChange:
                  self.minimum_changed.emit(self.minimum())
                  self.maximum_changed.emit(self.maximum())
              super().sliderChange(change)
      
      
      class SpanSlider(NewOptionSliderMixin, QSlider):
          """https://github.com/commontk/CTK/blob/master/Libs/Widgets/ctkRangeSlider.cpp."""
      
          class Handles(Flag):
              """Handle identifiers."""
      
              NoHandle = auto()
              LeftHandle = auto()
              RightHandle = auto()
      
          QFlag(Handles)
      
          values_changed = Signal(int, int)
          left_value_changed = Signal(int)
          right_value_changed = Signal(int)
      
          def __init__(self, *args, **kwargs):
              super().__init__(*args, **kwargs)
              if self.orientation() == Qt.Vertical:
                  self.setOrientation(Qt.Horizontal)
                  warnings.warn(f"{self.__name__} orientation forced to horizontal.")
              self._left_value = self.minimum()
              self._right_value = self.maximum()
              self._value = 0
              self._click_offset = 0
              self._selected_handles = self.Handles.NoHandle
              self._left_handle_min_offset = 0
              self._left_handle_max_offset = 0
      
          @Slot(int, int)
          def set_values(self, left: int, right: int) -> None:
              """Set both handles."""
              lhno = self._left_handle_min_offset
              lhxo = self._left_handle_max_offset
              left_val = max(self.minimum() - lhno, min(min(left, right), self.maximum() - lhxo))
              right_val = max(self.minimum(), min(max(left, right), self.maximum()))
              emit_left_changed = left_val != self._left_value
              emit_right_changed = right_val != self._right_value
      
              self._left_value = left_val
              self._right_value = right_val
      
              # skip checking if position changed as value and position are the same
      
              if emit_left_changed or emit_right_changed:
                  self.values_changed.emit(self._left_value, self._right_value)
              if emit_left_changed:
                  self.left_value_changed.emit(self._left_value)
              if emit_right_changed:
                  self.right_value_changed.emit(self._right_value)
              if emit_left_changed or emit_right_changed:
                  self.update()
      
          @Slot(int)
          def set_left_handle_min_offset(self, offset: int) -> None:
              """Set delta between left handle's minimum and slider minimum."""
              self._left_handle_min_offset = offset
      
          @Slot(int)
          def set_left_handle_max_offset(self, offset: int) -> None:
              """Set delta between left handle's true maximum and slider maximum."""
              self._left_handle_max_offset = offset
      
          def paintEvent(self, event: "QPaintEvent") -> None:
              """Paint extra handles."""
              option = self._init_new_style_option_slider()
              option.sliderPosition = self._left_value
              style = self.style()
              left_rect = style.subControlRect(QStyle.CC_Slider, option, QStyle.SC_SliderHandle)
              option.sliderPosition = self._right_value
              right_rect = style.subControlRect(QStyle.CC_Slider, option, QStyle.SC_SliderHandle)
      
              # skip rendering the groove
      
              painter = QStylePainter(self)
              painter.setClipRect(left_rect)
              self._draw_left_handle(painter)
              painter.setClipRect(right_rect)
              self._draw_right_handle(painter)
      
          def mousePressEvent(self, event: "QMouseEvent") -> None:
              """Move multiple handles."""
              if self.minimum() == self.maximum() or event.buttons() ^ event.button():
                  event.ignore()
                  return
      
              handle_rect, handle = self._handle_at_pos(event.pos())
              if handle != self.Handles.NoHandle:
                  if handle == self.Handles.LeftHandle:
                      self._value = self._left_value
                  elif handle == self.Handles.RightHandle:
                      self._value = self._right_value
      
                  self._click_offset = event.pos().x() - handle_rect.left()
                  self.setSliderDown(True)
      
                  if self._selected_handles != handle:
                      self._selected_handles = handle
                      self.update(handle_rect)
                  event.accept()
                  return
      
              # ignore detecting groove click since we're not painting it.
              event.ignore()
      
          def mouseMoveEvent(self, event: "QMouseEvent") -> None:
              """Move a handle if necessary."""
              if self._selected_handles == self.Handles.NoHandle:
                  event.ignore()
                  return
      
              event_x = event.pos().x()
              option = self._init_new_style_option_slider()
              max_drag_dist = self.style().pixelMetric(QStyle.PM_MaximumDragDistance, option)
              new_value = self._pixel_pos_to_range_value(event_x - self._click_offset)
              if max_drag_dist >= 0:
                  rect = self.rect().adjust(-max_drag_dist, -max_drag_dist, max_drag_dist, max_drag_dist)
                  if not rect.contains(event.pos()):
                      new_value = self._value
      
              if self._is_left_down() and not self._is_right_down():
                  new_left_value = min(new_value, self._right_value)
                  self.set_values(new_left_value, self._right_value)
              elif self._is_right_down() and not self._is_left_down():
                  mew_right_value = max(self._left_value, new_value)
                  self.set_values(self._left_value, mew_right_value)
              # ignore clicking both handlers/clicking between handles
              event.accept()
      
          def mouseReleaseEvent(self, event: "QMouseEvent") -> None:
              """Reset handles parent method executes."""
              super().mouseReleaseEvent(event)
              self.setSliderDown(False)
              self._selected_handles = self.Handles.NoHandle
              self.update()
      
          def _handle_at_pos(self, pos: "QPoint") -> tuple["QRect", Handles]:
              option = self._init_new_style_option_slider()
      
              # test left handle
              option.sliderPosition = option.sliderValue = self._left_value
              style = self.style()
              left_control = style.hitTestComplexControl(QStyle.CC_Slider, option, pos)
              left_rect = style.subControlRect(QStyle.CC_Slider, option, QStyle.SC_SliderHandle)
      
              # test right handle
              option.sliderPosition = option.sliderValue = self._right_value
              right_control = style.hitTestComplexControl(QStyle.CC_Slider, option, pos)
              right_rect = style.subControlRect(QStyle.CC_Slider, option, QStyle.SC_SliderHandle)
      
              # if above both handles, pick the closest one
              if left_control == QStyle.SC_SliderHandle and right_control == QStyle.SC_SliderHandle:
                  min_dist = pos.x() - left_rect.left()
                  max_dist = right_rect.right() - pos.x()
                  # ignore vertical orientation
                  left_control = left_control if min_dist < max_dist else QStyle.SC_None
      
              if left_control == QStyle.SC_SliderHandle:
                  return left_rect, self.Handles.LeftHandle
              if right_control == QStyle.SC_SliderHandle:
                  return right_rect, self.Handles.RightHandle
              return left_rect.united(right_rect), self.Handles.NoHandle
      
          def _pixel_pos_to_range_value(self, pos: int) -> int:
              option = self._init_new_style_option_slider()
              groove_rect = self.style().subControlRect(QStyle.CC_Slider, option, QStyle.SC_SliderGroove)
              handle_rect = self.style().subControlRect(QStyle.CC_Slider, option, QStyle.SC_SliderHandle)
              slider_length = handle_rect.width()
              slider_min = groove_rect.x()
              slider_max = groove_rect.right() - slider_length + 1
      
              span = slider_max - slider_min
              pos -= slider_min
              value = QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), pos, span)
              return value
      
          def _draw_left_handle(self, painter: QStylePainter) -> None:
              option = self._init_new_style_option_slider()
              option.subControls = QStyle.SC_SliderHandle
              option.sliderValue = option.sliderPosition = self._left_value
              if self._is_left_down():
                  option.activeSubControls = QStyle.SC_SliderHandle
                  option.state |= QStyle.State_Sunken
      
              painter.drawComplexControl(QStyle.CC_Slider, option)
      
          def _draw_right_handle(self, painter: QStylePainter) -> None:
              option = self._init_new_style_option_slider()
              option.subControls = QStyle.SC_SliderHandle
              option.sliderValue = option.sliderPosition = self._right_value
              if self._is_right_down():
                  option.activeSubControls = QStyle.SC_SliderHandle
                  option.state |= QStyle.State_Sunken
      
              # skip MacOS groove workarounds since we're not painting it
      
              painter.drawComplexControl(QStyle.CC_Slider, option)
      
          def _is_left_down(self) -> bool:
              return self._selected_handles == self.Handles.LeftHandle
      
          def _is_right_down(self) -> bool:
              return self._selected_handles == self.Handles.RightHandle
      
      
      class Ruler(QWidget):
          """Word processing ruler."""
      
          text_indent_value_changed = Signal(float)
          margin_left_value_changed = Signal(float)
          margin_right_value_changed = Signal(float)
      
          def __init__(self, *args, **kwargs):
              super().__init__(*args, **kwargs)
              self._indent_slider = RelativeSlider(Qt.Horizontal)
              self._indent_slider.delta_changed.connect(self._text_indent_to_px)
              self._indent_slider.setTickInterval(10)  # per inch
      
              layout = QVBoxLayout()
              layout.setSpacing(0)
              layout.setContentsMargins(0, 0, 0, 0)
              layout.addWidget(self._indent_slider)
      
              self._margins_slider = SpanSlider(Qt.Horizontal)
              self._margins_slider.left_value_changed.connect(self._margin_left_to_px)
              self._margins_slider.left_value_changed.connect(self._indent_slider.set_external_value)
              self._margins_slider.right_value_changed.connect(self._margin_right_to_px)
      
              self._indent_slider.maximum_changed.connect(self._margins_slider.setMaximum)
              self._indent_slider.minimum_changed.connect(self._margins_slider.setMinimum)
              self._indent_slider.delta_changed.connect(self._margins_slider.set_left_handle_min_offset)
              self._indent_slider.delta_changed.connect(self._margins_slider.set_left_handle_max_offset)
              self._indent_slider.setMaximum(self._margins_slider.maximum())
              self._indent_slider.setMinimum(self._margins_slider.minimum())
              layout.addWidget(self._margins_slider)
              self.setLayout(layout)
      
          @Slot(int)
          def _text_indent_to_px(self, value: int) -> None:
              self.text_indent_value_changed.emit(value / self._value_per_pixel())
      
          @Slot(int)
          def _margin_left_to_px(self, value: int) -> None:
              self.margin_left_value_changed.emit(value / self._value_per_pixel())
      
          @Slot(int)
          def _margin_right_to_px(self, value: int) -> None:
              pixels = (self._indent_slider.maximum() - value) / self._value_per_pixel()
              self.margin_right_value_changed.emit(pixels)
      
          def _value_per_pixel(self) -> float:
              return self._indent_slider.maximum() / self.width()
      
      
      class TextEdit(QWidget):
          """The Main window"""
      
          def __init__(self, *args, **kwargs) -> None:
              super().__init__(*args, **kwargs)
              ruler = Ruler()
              ruler.text_indent_value_changed.connect(self._set_text_indent)
              ruler.margin_left_value_changed.connect(self._set_margin_left)
              ruler.margin_right_value_changed.connect(self._set_margin_right)
              layout = QVBoxLayout()
              layout.addWidget(ruler)
      
              self.text_edit = QTextEdit()
              layout.addWidget(self.text_edit)
              self.setLayout(layout)
      
          @Slot(int)
          def _set_text_indent(self, indent: int) -> None:
              block_format = self.text_edit.textCursor().blockFormat()
              block_format.setTextIndent(indent)
              self._merge_block_format(block_format)
      
          @Slot(int)
          def _set_margin_left(self, margin: int) -> None:
              block_format = self.text_edit.textCursor().blockFormat()
              block_format.setLeftMargin(margin)
              self._merge_block_format(block_format)
      
          @Slot(int)
          def _set_margin_right(self, margin: int) -> None:
              block_format = self.text_edit.textCursor().blockFormat()
              block_format.setRightMargin(margin)
              self._merge_block_format(block_format)
      
          def _merge_block_format(self, block_format: "QTextBlockFormat") -> None:
              text_cursor = self.text_edit.textCursor()
              text_cursor.mergeBlockFormat(block_format)
              self.text_edit.setTextCursor(text_cursor)
      
      
      if __name__ == "__main__":
          app = QApplication([])
          mw = TextEdit()
          mw.resize(600, mw.height())
          mw.show()
          sys.exit(app.exec())
      
      
      1 Reply Last reply
      0

      1/2

      26 Jan 2022, 01:51

      • Login

      • Login or register to search.
      1 out of 2
      • First post
        1/2
        Last post
      0
      • Categories
      • Recent
      • Tags
      • Popular
      • Users
      • Groups
      • Search
      • Get Qt Extensions
      • Unsolved