From 9c7cb7ba2bb02fda220c4a52718ee349b05080a4 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 26 Dec 2025 09:03:20 +1100 Subject: [PATCH] Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used. --- CHANGELOG.md | 4 + bouquin/main_window.py | 59 ++-- bouquin/markdown_editor.py | 579 +++++++++++++++++++++++++++++++++++-- bouquin/toolbar.py | 15 +- 4 files changed, 592 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d94ebeb..259d9ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.8.1 + + * Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used. + # 0.8.0 * Add .desktop file for Debian diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 9b812b4..0cebf24 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -28,7 +28,6 @@ from PySide6.QtGui import ( QGuiApplication, QKeySequence, QTextCursor, - QTextListFormat, ) from PySide6.QtWidgets import ( QApplication, @@ -1241,46 +1240,58 @@ class MainWindow(QMainWindow): self._toolbar_bound = True def _sync_toolbar(self): - fmt = self.editor.currentCharFormat() + """ + Keep the toolbar "sticky" by reflecting the markdown state at the current caret/selection. + """ c = self.editor.textCursor() + line = c.block().text() + + # Inline styles (markdown-aware) + bold_on = bool(getattr(self.editor, "is_markdown_bold_active", lambda: False)()) + italic_on = bool( + getattr(self.editor, "is_markdown_italic_active", lambda: False)() + ) + strike_on = bool( + getattr(self.editor, "is_markdown_strike_active", lambda: False)() + ) # Block signals so setChecked() doesn't re-trigger actions QSignalBlocker(self.toolBar.actBold) QSignalBlocker(self.toolBar.actItalic) QSignalBlocker(self.toolBar.actStrike) - self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) - self.toolBar.actItalic.setChecked(fmt.fontItalic()) - self.toolBar.actStrike.setChecked(fmt.fontStrikeOut()) + self.toolBar.actBold.setChecked(bold_on) + self.toolBar.actItalic.setChecked(italic_on) + self.toolBar.actStrike.setChecked(strike_on) - # Headings: decide which to check by current point size - def _approx(a, b, eps=0.5): # small float tolerance - return abs(float(a) - float(b)) <= eps - - cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF() - - bH1 = _approx(cur_size, 24) - bH2 = _approx(cur_size, 18) - bH3 = _approx(cur_size, 14) + # Headings: infer from leading markdown markers + heading_level = 0 + m = re.match(r"^\s*(#{1,3})\s+", line) + if m: + heading_level = len(m.group(1)) QSignalBlocker(self.toolBar.actH1) QSignalBlocker(self.toolBar.actH2) QSignalBlocker(self.toolBar.actH3) QSignalBlocker(self.toolBar.actNormal) - self.toolBar.actH1.setChecked(bH1) - self.toolBar.actH2.setChecked(bH2) - self.toolBar.actH3.setChecked(bH3) - self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3)) + self.toolBar.actH1.setChecked(heading_level == 1) + self.toolBar.actH2.setChecked(heading_level == 2) + self.toolBar.actH3.setChecked(heading_level == 3) + self.toolBar.actNormal.setChecked(heading_level == 0) + + # Lists: infer from leading markers on the current line + bullets_on = bool(re.match(r"^\s*(?:•|-|\*)\s+", line)) + numbers_on = bool(re.match(r"^\s*\d+\.\s+", line)) + checkboxes_on = bool(re.match(r"^\s*[☐☑]\s+", line)) - # Lists - lst = c.currentList() - bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc - numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal QSignalBlocker(self.toolBar.actBullets) QSignalBlocker(self.toolBar.actNumbers) - self.toolBar.actBullets.setChecked(bool(bullets_on)) - self.toolBar.actNumbers.setChecked(bool(numbers_on)) + QSignalBlocker(self.toolBar.actCheckboxes) + + self.toolBar.actBullets.setChecked(bullets_on) + self.toolBar.actNumbers.setChecked(numbers_on) + self.toolBar.actCheckboxes.setChecked(checkboxes_on) def _change_font_size(self, delta: int) -> None: """Change font size for all editor tabs and save the setting.""" diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 849f515..b1a6d66 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -46,7 +46,7 @@ class MarkdownEditor(QTextEdit): _COLLAPSE_LABEL_COLLAPSE = "collapse" _COLLAPSE_LABEL_EXPAND = "expand" _COLLAPSE_END_MARKER = "" - # Accept either "collapse" or "expand" in the header text (older files used only "collapse") + # Accept either "collapse" or "expand" in the header text _COLLAPSE_HEADER_RE = re.compile(r"^([ \t]*)([▸▾])\s+(?:collapse|expand)\s*$") _COLLAPSE_END_RE = re.compile(r"^([ \t]*)\s*$") @@ -114,6 +114,9 @@ class MarkdownEditor(QTextEdit): # Track if we're currently updating text programmatically self._updating = False + # Track pending inline marker insertion (e.g. Italic with no selection) + self._pending_inline_marker: str | None = None + # Help avoid double-click selecting of checkbox self._suppress_next_checkbox_double_click = False @@ -928,6 +931,69 @@ class MarkdownEditor(QTextEdit): return None + def _maybe_skip_over_marker_run(self, key: Qt.Key) -> bool: + """Skip over common markdown marker runs when navigating with Left/Right. + + This prevents the caret from landing *inside* runs like '**', '***', '__', '___' or '~~', + which can cause temporary toolbar-state flicker and makes navigation feel like it takes + "two presses" to get past closing markers. + + Hold any modifier key (Shift/Ctrl/Alt/Meta) to disable this behavior. + """ + c = self.textCursor() + if c.hasSelection(): + return False + + p = c.position() + doc_max = self._doc_max_pos() + + # Right: run starts at the caret + if key == Qt.Key.Key_Right: + if p >= doc_max: + return False + ch = self._text_range(p, p + 1) + if ch not in ("*", "_", "~"): + return False + + run = 0 + while p + run < doc_max and self._text_range(p + run, p + run + 1) == ch: + run += 1 + + # Only skip multi-char runs (bold/strong/emphasis runs or strike) + if ch in ("*", "_") and run >= 2: + c.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, run) + self.setTextCursor(c) + return True + if ch == "~" and run == 2: + c.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 2) + self.setTextCursor(c) + return True + + return False + + # Left: run ends at the caret + if key == Qt.Key.Key_Left: + if p <= 0: + return False + ch = self._text_range(p - 1, p) + if ch not in ("*", "_", "~"): + return False + + run = 0 + while p - 1 - run >= 0 and self._text_range(p - 1 - run, p - run) == ch: + run += 1 + + if ch in ("*", "_") and run >= 2: + c.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, run) + self.setTextCursor(c) + return True + if ch == "~" and run == 2: + c.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 2) + self.setTextCursor(c) + return True + + return False + def keyPressEvent(self, event): """Handle special key events for markdown editing.""" c = self.textCursor() @@ -936,7 +1002,6 @@ class MarkdownEditor(QTextEdit): in_code = self._is_inside_code_block(block) is_fence_line = block.text().strip().startswith("```") - # --- NEW: 3rd backtick shortcut → open code block dialog --- # Only when we're *not* already in a code block or on a fence line. if event.text() == "`" and not (in_code or is_fence_line): line = block.text() @@ -1002,6 +1067,14 @@ class MarkdownEditor(QTextEdit): super().keyPressEvent(event) return + if ( + event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right) + and event.modifiers() == Qt.KeyboardModifier.NoModifier + and not self.textCursor().hasSelection() + ): + if self._maybe_skip_over_marker_run(event.key()): + return + # --- Step out of a code block with Down at EOF --- if event.key() == Qt.Key.Key_Down: c = self.textCursor() @@ -1509,19 +1582,389 @@ class MarkdownEditor(QTextEdit): # ------------------------ Toolbar action handlers ------------------------ + # ------------------------ Inline markdown helpers ------------------------ + + def _doc_max_pos(self) -> int: + # QTextDocument includes a trailing null character; cursor positions stop before it. + doc = self.document() + return max(0, doc.characterCount() - 1) + + def _text_range(self, start: int, end: int) -> str: + """Return document text between [start, end) using QTextCursor indexing.""" + doc_max = self._doc_max_pos() + start = max(0, min(start, doc_max)) + end = max(0, min(end, doc_max)) + if end < start: + start, end = end, start + tc = QTextCursor(self.document()) + tc.setPosition(start) + tc.setPosition(end, QTextCursor.KeepAnchor) + return tc.selectedText() + + def _selection_wrapped_by( + self, + markers: tuple[str, ...], + *, + require_singletons: bool = False, + ) -> str | None: + """ + If the current selection is wrapped by any marker in `markers`, return the marker. + + Supports both cases: + 1) the selection itself includes the markers, e.g. "**bold**" + 2) the selection is the inner text, with markers immediately adjacent in the doc. + """ + c = self.textCursor() + if not c.hasSelection(): + return None + + sel = c.selectedText() + start = c.selectionStart() + end = c.selectionEnd() + doc_max = self._doc_max_pos() + + # Case 1: selection includes markers + for m in markers: + lm = len(m) + if len(sel) >= 2 * lm and sel.startswith(m) and sel.endswith(m): + return m + + # Case 2: markers adjacent to selection + for m in markers: + lm = len(m) + if start < lm or end + lm > doc_max: + continue + before = self._text_range(start - lm, start) + after = self._text_range(end, end + lm) + if before != m or after != m: + continue + + if require_singletons and lm == 1: + # Ensure the single marker isn't part of a double/triple (e.g. "**" or "__") + ch = m + left_marker_pos = start - 1 + right_marker_pos = end + + if ( + left_marker_pos - 1 >= 0 + and self._text_range(left_marker_pos - 1, left_marker_pos) == ch + ): + continue + if ( + right_marker_pos + 1 <= doc_max + and self._text_range(right_marker_pos + 1, right_marker_pos + 2) + == ch + ): + continue + + return m + + return None + + def _caret_between_markers( + self, marker: str, *, require_singletons: bool = False + ) -> bool: + """True if the caret is exactly between an opening and closing marker (e.g. **|**).""" + c = self.textCursor() + if c.hasSelection(): + return False + + p = c.position() + lm = len(marker) + doc_max = self._doc_max_pos() + if p < lm or p + lm > doc_max: + return False + + before = self._text_range(p - lm, p) + after = self._text_range(p, p + lm) + if before != marker or after != marker: + return False + + if require_singletons and lm == 1: + # Disallow if either side is adjacent to the same char (part of "**", "__", "***", etc.) + ch = marker + if p - 2 >= 0 and self._text_range(p - 2, p - 1) == ch: + return False + if p + 1 <= doc_max and self._text_range(p + 1, p + 2) == ch: + return False + + return True + + def _caret_before_marker( + self, marker: str, *, require_singletons: bool = False + ) -> bool: + """True if the caret is immediately before `marker` (e.g. |**).""" + c = self.textCursor() + if c.hasSelection(): + return False + + p = c.position() + lm = len(marker) + doc_max = self._doc_max_pos() + if p + lm > doc_max: + return False + + after = self._text_range(p, p + lm) + if after != marker: + return False + + if require_singletons and lm == 1: + # Disallow if it's part of a run like "**" or "___". + ch = marker + if p - 1 >= 0 and self._text_range(p - 1, p) == ch: + return False + if p + 1 <= doc_max and self._text_range(p + 1, p + 2) == ch: + return False + + return True + + def _unwrap_selection( + self, marker: str, *, replacement_marker: str | None = None + ) -> bool: + """ + Remove `marker` wrapping from the selection. + If replacement_marker is provided, replace marker with that (e.g. ***text*** -> *text*). + """ + c = self.textCursor() + if not c.hasSelection(): + return False + + sel = c.selectedText() + start = c.selectionStart() + end = c.selectionEnd() + lm = len(marker) + doc_max = self._doc_max_pos() + + def _select_inner( + edit_cursor: QTextCursor, inner_start: int, inner_len: int + ) -> None: + edit_cursor.setPosition(inner_start) + edit_cursor.setPosition(inner_start + inner_len, QTextCursor.KeepAnchor) + self.setTextCursor(edit_cursor) + + # Case 1: selection includes markers + if len(sel) >= 2 * lm and sel.startswith(marker) and sel.endswith(marker): + inner = sel[lm:-lm] + new_text = ( + f"{replacement_marker}{inner}{replacement_marker}" + if replacement_marker is not None + else inner + ) + c.beginEditBlock() + c.insertText(new_text) + c.endEditBlock() + + # Re-select the inner content (not the markers) + inner_start = c.position() - len(new_text) + if replacement_marker is not None: + inner_start += len(replacement_marker) + _select_inner(c, inner_start, len(inner)) + return True + + # Case 2: marker is adjacent to selection + if start >= lm and end + lm <= doc_max: + before = self._text_range(start - lm, start) + after = self._text_range(end, end + lm) + if before == marker and after == marker: + new_text = ( + f"{replacement_marker}{sel}{replacement_marker}" + if replacement_marker is not None + else sel + ) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(start - lm) + edit.setPosition(end + lm, QTextCursor.KeepAnchor) + edit.insertText(new_text) + edit.endEditBlock() + + inner_start = (start - lm) + ( + len(replacement_marker) if replacement_marker else 0 + ) + _select_inner(edit, inner_start, len(sel)) + return True + + return False + + def _wrap_selection(self, marker: str) -> None: + """Wrap the current selection with `marker` and keep the content selected.""" + c = self.textCursor() + if not c.hasSelection(): + return + sel = c.selectedText() + start = c.selectionStart() + lm = len(marker) + + c.beginEditBlock() + c.insertText(f"{marker}{sel}{marker}") + c.endEditBlock() + + # Re-select the original content + edit = QTextCursor(self.document()) + edit.setPosition(start + lm) + edit.setPosition(start + lm + len(sel), QTextCursor.KeepAnchor) + self.setTextCursor(edit) + + def _pos_inside_inline_span( + self, + patterns: list[tuple[re.Pattern, int]], + start_in_block: int, + end_in_block: int, + ) -> bool: + """True if [start_in_block, end_in_block] lies within the content region of any pattern match.""" + block_text = self.textCursor().block().text() + for pat, mlen in patterns: + for m in pat.finditer(block_text): + s, e = m.span() + cs, ce = s + mlen, e - mlen + if cs <= start_in_block and end_in_block <= ce: + return True + return False + + def is_markdown_bold_active(self) -> bool: + c = self.textCursor() + bold_markers = ("***", "___", "**", "__") + + if c.hasSelection(): + if self._selection_wrapped_by(bold_markers) is not None: + return True + block = c.block() + start_in_block = c.selectionStart() - block.position() + end_in_block = c.selectionEnd() - block.position() + patterns = [ + (re.compile(r"(? bool: + c = self.textCursor() + italic_markers = ("*", "_", "***", "___") + + if c.hasSelection(): + if ( + self._selection_wrapped_by(italic_markers, require_singletons=True) + is not None + ): + return True + block = c.block() + start_in_block = c.selectionStart() - block.position() + end_in_block = c.selectionEnd() - block.position() + patterns = [ + (re.compile(r"(? bool: + c = self.textCursor() + if c.hasSelection(): + if self._selection_wrapped_by(("~~",)) is not None: + return True + block = c.block() + start_in_block = c.selectionStart() - block.position() + end_in_block = c.selectionEnd() - block.position() + patterns = [(re.compile(r"~~(.+?)~~"), 2)] + return self._pos_inside_inline_span(patterns, start_in_block, end_in_block) + + if self._caret_between_markers("~~"): + return True + block = c.block() + pos_in_block = c.position() - block.position() + patterns = [(re.compile(r"~~(.+?)~~"), 2)] + return self._pos_inside_inline_span(patterns, pos_in_block, pos_in_block) + + # ------------------------ Toolbar action handlers ------------------------ + def apply_weight(self): - """Toggle bold formatting.""" + """Toggle bold formatting (markdown ** / __, and *** / ___).""" cursor = self.textCursor() + if cursor.hasSelection(): - selected = cursor.selectedText() - # Check if already bold - if selected.startswith("**") and selected.endswith("**"): - # Remove bold - new_text = selected[2:-2] - else: - # Add bold - new_text = f"**{selected}**" - cursor.insertText(new_text) + # If bold+italic, toggling bold should leave italic: ***text*** -> *text* + m = self._selection_wrapped_by(("***", "___")) + if m is not None: + repl = "*" if m == "***" else "_" + if self._unwrap_selection(m, replacement_marker=repl): + self.setFocus() + return + + # Normal bold: **text** / __text__ + m = self._selection_wrapped_by(("**", "__")) + if m is not None: + if self._unwrap_selection(m): + self.setFocus() + return + + # Not bold: wrap selection with ** + self._wrap_selection("**") + self.setFocus() + return + + # No selection: + # - If we're between an empty pair (**|**), remove them. + # - If we're inside bold and sitting right before the closing marker (**text|**), + # jump the caret *past* the marker (end-bold) instead of inserting more. + # - Otherwise, insert a new empty pair and place the caret between. + if self._caret_between_markers("**") or self._caret_between_markers("__"): + marker = "**" if self._caret_between_markers("**") else "__" + p = cursor.position() + lm = len(marker) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(p - lm) + edit.setPosition(p + lm, QTextCursor.KeepAnchor) + edit.insertText("") + edit.endEditBlock() + edit.setPosition(p - lm) + self.setTextCursor(edit) + elif self.is_markdown_bold_active() and ( + self._caret_before_marker("**") or self._caret_before_marker("__") + ): + marker = "**" if self._caret_before_marker("**") else "__" + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.MoveAnchor, + len(marker), + ) + self.setTextCursor(cursor) + self._pending_inline_marker = None else: # No selection - just insert markers cursor.insertText("****") @@ -1529,44 +1972,120 @@ class MarkdownEditor(QTextEdit): QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2 ) self.setTextCursor(cursor) + self._pending_inline_marker = "*" # Return focus to editor self.setFocus() def apply_italic(self): - """Toggle italic formatting.""" + """Toggle italic formatting (markdown * / _, and *** / ___).""" cursor = self.textCursor() + if cursor.hasSelection(): - selected = cursor.selectedText() - if ( - selected.startswith("*") - and selected.endswith("*") - and not selected.startswith("**") - ): - new_text = selected[1:-1] - else: - new_text = f"*{selected}*" - cursor.insertText(new_text) + # If bold+italic, toggling italic should leave bold: ***text*** -> **text** + m = self._selection_wrapped_by(("***", "___")) + if m is not None: + repl = "**" if m == "***" else "__" + if self._unwrap_selection(m, replacement_marker=repl): + self.setFocus() + return + + m = self._selection_wrapped_by(("*", "_"), require_singletons=True) + if m is not None: + if self._unwrap_selection(m): + self.setFocus() + return + + self._wrap_selection("*") + self.setFocus() + return + + # No selection: + # - If we're between an empty pair (*|*), remove them. + # - If we're inside italic and sitting right before the closing marker (*text|*), + # jump the caret past the marker (end-italic) instead of inserting more. + # - Otherwise, insert a new empty pair and place the caret between. + if self._caret_between_markers( + "*", require_singletons=True + ) or self._caret_between_markers("_", require_singletons=True): + marker = ( + "*" + if self._caret_between_markers("*", require_singletons=True) + else "_" + ) + p = cursor.position() + lm = len(marker) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(p - lm) + edit.setPosition(p + lm, QTextCursor.KeepAnchor) + edit.insertText("") + edit.endEditBlock() + edit.setPosition(p - lm) + self.setTextCursor(edit) + self._pending_inline_marker = None + elif self.is_markdown_italic_active() and ( + self._caret_before_marker("*", require_singletons=True) + or self._caret_before_marker("_", require_singletons=True) + ): + marker = ( + "*" if self._caret_before_marker("*", require_singletons=True) else "_" + ) + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.MoveAnchor, + len(marker), + ) + self.setTextCursor(cursor) + self._pending_inline_marker = None else: cursor.insertText("**") cursor.movePosition( QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1 ) self.setTextCursor(cursor) + self._pending_inline_marker = "*" # Return focus to editor self.setFocus() def apply_strikethrough(self): - """Toggle strikethrough formatting.""" + """Toggle strikethrough formatting (markdown ~~).""" cursor = self.textCursor() + if cursor.hasSelection(): - selected = cursor.selectedText() - if selected.startswith("~~") and selected.endswith("~~"): - new_text = selected[2:-2] - else: - new_text = f"~~{selected}~~" - cursor.insertText(new_text) + m = self._selection_wrapped_by(("~~",)) + if m is not None: + if self._unwrap_selection(m): + self.setFocus() + return + self._wrap_selection("~~") + self.setFocus() + return + + # No selection: + # - If we're between an empty pair (~~|~~), remove them. + # - If we're inside strike and sitting right before the closing marker (~~text|~~), + # jump the caret past the marker (end-strike) instead of inserting more. + # - Otherwise, insert a new empty pair and place the caret between. + if self._caret_between_markers("~~"): + p = cursor.position() + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(p - 2) + edit.setPosition(p + 2, QTextCursor.KeepAnchor) + edit.insertText("") + edit.endEditBlock() + edit.setPosition(p - 2) + self.setTextCursor(edit) + elif self.is_markdown_strike_active() and self._caret_before_marker("~~"): + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.MoveAnchor, + 2, + ) + self.setTextCursor(cursor) + self._pending_inline_marker = None else: cursor.insertText("~~~~") cursor.movePosition( diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 92383e6..8e8c4bf 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -98,6 +98,7 @@ class ToolBar(QToolBar): self.actNumbers.triggered.connect(self.numbersRequested) self.actCheckboxes = QAction("☑", self) self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes")) + self.actCheckboxes.setCheckable(True) self.actCheckboxes.triggered.connect(self.checkboxesRequested) # Images @@ -126,22 +127,14 @@ class ToolBar(QToolBar): self.actDocuments = QAction("📁", self) self.actDocuments.setToolTip(strings._("toolbar_documents")) self.actDocuments.triggered.connect(self.documentsRequested) - - # Set exclusive buttons in QActionGroups + # Headings are mutually exclusive (like radio buttons) self.grpHeadings = QActionGroup(self) self.grpHeadings.setExclusive(True) - for a in ( - self.actBold, - self.actItalic, - self.actStrike, - self.actH1, - self.actH2, - self.actH3, - self.actNormal, - ): + for a in (self.actH1, self.actH2, self.actH3, self.actNormal): a.setCheckable(True) a.setActionGroup(self.grpHeadings) + # List types are mutually exclusive self.grpLists = QActionGroup(self) self.grpLists.setExclusive(True) for a in (self.actBullets, self.actNumbers, self.actCheckboxes):