Making a word processor ruler with Qt
-
wrote on 26 Jan 2022, 01:51 last edited by duyjuwumu
I'd want to make something like below with Qt.
It looks like someone else tried and failed to do this in 2017. I was able to get a prototype working using threeQSlider
s. 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
QSlider
s 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 usingsetGeometry
to manually position theQSlider
s, 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 threeQSlider
s 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++.
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())
-
wrote on 31 Jan 2022, 01:20 last edited by
Progress!... but still not completely solved. See below for the help needed
The ruler above is actually twoQSlider
s. 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 TopQSlider
uses the built-insetValue
-
Overwrite
paintEvent
usingQStyleOptionSlider
andQStylePainter
. Specifically,QStylePainter.drawComplexControl
to drawQStyle.SC_SliderHandle
. -
Overwrite
mousePressEvent
,mouseMoveEvent
, andmousePressEvent
. I'm keeping track of the location of each handle and which one is currently pressed with enums and instance variables. I portedhandleAtPos
method andpixelPosToRangeValue
andpixelPosFromRangeValue
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 ofQTextEdit
. The larger the slider value, the more the text is offset from the slider handle.
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/2