Skip to content

Commit 0c8fa09

Browse files
committed
fix(a11y): add keyboard shortcuts, accessible descriptions, and wcag contrast to find-replace dialog
1 parent 2e4280f commit 0c8fa09

3 files changed

Lines changed: 156 additions & 9 deletions

File tree

tests/test_ui_ux_accessibility.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"""Unit- und Contract-Tests für UI-, UX-, Accessibility- und Sprach-Qualität in CodeBox."""
44

55
import pytest
6+
from PySide6.QtCore import Qt, QEvent
7+
from PySide6.QtGui import QKeyEvent
68
from PySide6.QtWidgets import QApplication
79
from unittest.mock import patch
810

@@ -11,6 +13,7 @@
1113
from ui.shortcuts_dialog import ShortcutsDialog, SHORTCUTS_DATA
1214
from ui.plugins_dialog import PluginsDialog
1315
from ui.problems_panel import ProblemsPanel
16+
from ui.search_dialog import FindReplaceDialog
1417
from core.tabs import TabWidget
1518
from core.output import OutputPanel
1619

@@ -171,3 +174,99 @@ def test_problems_and_output_panel_a11y(qapp):
171174
assert output.stop_btn.accessibleName() == "Ausführung stoppen"
172175
assert output.clear_btn.accessibleName() == "Ausgabe leeren"
173176
assert output.output.accessibleName() == "Programmausgabe"
177+
178+
179+
def test_find_replace_dialog_ux_and_a11y(qapp):
180+
"""Prüft Barrierefreiheit, Buddies, Tastenkürzel, Tab-Reihenfolge und Kontrast des Suchdialogs."""
181+
dialog = FindReplaceDialog(None)
182+
try:
183+
# Buddies für Screenreader & mnemonics
184+
assert dialog.search_label.buddy() == dialog.search_input
185+
assert dialog.replace_label.buddy() == dialog.replace_input
186+
187+
# Accessible Attributes & Tooltips
188+
assert dialog.search_input.accessibleName() == "Suchtext"
189+
assert len(dialog.search_input.accessibleDescription()) > 10
190+
assert "Shift+Enter" in dialog.search_input.toolTip()
191+
192+
assert dialog.btn_prev.accessibleName() == "Vorheriger Treffer"
193+
assert len(dialog.btn_prev.accessibleDescription()) > 10
194+
195+
assert dialog.btn_next.accessibleName() == "Nächster Treffer"
196+
assert len(dialog.btn_next.accessibleDescription()) > 10
197+
198+
assert dialog.replace_input.accessibleName() == "Ersetzungstext"
199+
assert len(dialog.replace_input.accessibleDescription()) > 10
200+
201+
assert dialog.btn_replace.accessibleName() == "Treffer ersetzen"
202+
assert len(dialog.btn_replace.accessibleDescription()) > 10
203+
assert dialog.btn_replace.shortcut().toString() == "Alt+R"
204+
205+
assert dialog.btn_replace_all.accessibleName() == "Alle Treffer ersetzen"
206+
assert len(dialog.btn_replace_all.accessibleDescription()) > 10
207+
assert dialog.btn_replace_all.shortcut().toString() == "Alt+A"
208+
209+
assert dialog.cb_case.accessibleName() == "Groß- und Kleinschreibung beachten"
210+
assert len(dialog.cb_case.accessibleDescription()) > 10
211+
212+
assert dialog.cb_words.accessibleName() == "Nur ganze Wörter suchen"
213+
assert len(dialog.cb_words.accessibleDescription()) > 10
214+
215+
assert dialog.cb_regex.accessibleName() == "Regulären Ausdruck verwenden"
216+
assert len(dialog.cb_regex.accessibleDescription()) > 10
217+
218+
assert dialog.status_label.accessibleName() == "Suchstatus"
219+
assert len(dialog.status_label.accessibleDescription()) > 10
220+
221+
assert dialog.btn_toggle_preview.accessibleName() == "Voransicht umschalten"
222+
assert len(dialog.btn_toggle_preview.accessibleDescription()) > 10
223+
assert dialog.btn_toggle_preview.shortcut().toString() == "Alt+V"
224+
225+
assert dialog.preview_table.accessibleName() == "Voransichtstabelle"
226+
assert len(dialog.preview_table.accessibleDescription()) > 10
227+
228+
assert dialog.btn_close.accessibleName() == "Dialog schließen"
229+
assert len(dialog.btn_close.accessibleDescription()) > 10
230+
finally:
231+
dialog.close()
232+
233+
234+
def test_find_replace_keyboard_and_focus_a11y(qapp):
235+
"""Prüft Shift+Enter im Suchfeld, itemActivated auf Tabelle und Fokus-Rückgabe an den Editor."""
236+
window = MainWindow()
237+
window.new_file()
238+
tab = window.tab_widget.current_tab()
239+
tab.editor.setPlainText("item_alpha\nitem_beta\nitem_alpha")
240+
241+
dialog = FindReplaceDialog(window, initial_mode="find")
242+
dialog.show()
243+
qapp.processEvents()
244+
245+
try:
246+
# Suchen nach item_alpha
247+
dialog.search_input.setText("item_alpha")
248+
dialog._update_live_results()
249+
assert dialog.preview_table.rowCount() == 2
250+
251+
# Tabelle: itemActivated (Tastatur-Enter auf Element) springt zum Treffer
252+
item = dialog.preview_table.item(1, 0)
253+
dialog.preview_table.itemActivated.emit(item)
254+
assert tab.editor.textCursor().selectedText() == "item_alpha"
255+
assert tab.editor.textCursor().selectionStart() == 21
256+
257+
# Shift+Enter im Suchfeld löst find_prev aus
258+
shift_enter = QKeyEvent(
259+
QEvent.Type.KeyPress,
260+
Qt.Key.Key_Return,
261+
Qt.KeyboardModifier.ShiftModifier,
262+
)
263+
handled = dialog.eventFilter(dialog.search_input, shift_enter)
264+
assert handled is True
265+
# Sprang rückwärts zum ersten Treffer bei Position 0
266+
assert tab.editor.textCursor().selectionStart() == 0
267+
268+
# Schließen des Dialogs gibt Fokus an den Editor zurück
269+
dialog.close()
270+
assert not dialog.isVisible()
271+
finally:
272+
window.close()

ui/search_dialog.py

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import TYPE_CHECKING, Optional
1111

1212
from PySide6.QtCore import Qt, QTimer, Signal
13+
from PySide6.QtGui import QKeySequence
1314
from PySide6.QtWidgets import (
1415
QCheckBox,
1516
QDialog,
@@ -62,27 +63,32 @@ def _setup_ui(self):
6263
grid.setSpacing(6)
6364

6465
# Suchen
65-
self.search_label = QLabel("Suchen nach:")
66+
self.search_label = QLabel("&Suchen nach:")
67+
self.search_label.setToolTip("Suchfeld für Text oder regulären Ausdruck (Alt+S)")
6668
self.search_input = QLineEdit()
6769
self.search_input.setObjectName("find_search_input")
6870
self.search_input.setPlaceholderText("Suchbegriff oder Regex...")
6971
self.search_input.setClearButtonEnabled(True)
70-
self.search_input.setToolTip("Suchbegriff oder regulären Ausdruck eingeben (Enter für nächsten Treffer)")
72+
self.search_input.setToolTip("Suchbegriff oder regulären Ausdruck eingeben (Enter für nächsten, Shift+Enter für vorherigen Treffer)")
7173
self.search_input.setAccessibleName("Suchtext")
7274
self.search_input.setAccessibleDescription("Eingabefeld für den zu suchenden Text oder regulären Ausdruck")
75+
self.search_label.setBuddy(self.search_input)
76+
self.search_input.installEventFilter(self)
7377
self.search_input.textChanged.connect(self._schedule_update)
7478
self.search_input.returnPressed.connect(self.find_next)
7579

7680
self.btn_prev = QPushButton("↑ Vorheriger")
7781
self.btn_prev.setObjectName("find_prev_btn")
7882
self.btn_prev.setToolTip("Vorherigen Treffer anspringen (Shift+Enter / Shift+F3)")
7983
self.btn_prev.setAccessibleName("Vorheriger Treffer")
84+
self.btn_prev.setAccessibleDescription("Springt zum vorherigen Treffer im Dokument (Shift+Enter oder Shift+F3)")
8085
self.btn_prev.clicked.connect(self.find_prev)
8186

8287
self.btn_next = QPushButton("↓ Nächster")
8388
self.btn_next.setObjectName("find_next_btn")
8489
self.btn_next.setToolTip("Nächsten Treffer anspringen (Enter / F3)")
8590
self.btn_next.setAccessibleName("Nächster Treffer")
91+
self.btn_next.setAccessibleDescription("Springt zum nächsten Treffer im Dokument (Enter oder F3)")
8692
self.btn_next.clicked.connect(self.find_next)
8793

8894
grid.addWidget(self.search_label, 0, 0)
@@ -91,27 +97,33 @@ def _setup_ui(self):
9197
grid.addWidget(self.btn_next, 0, 3)
9298

9399
# Ersetzen
94-
self.replace_label = QLabel("Ersetzen durch:")
100+
self.replace_label = QLabel("Erset&zen durch:")
101+
self.replace_label.setToolTip("Eingabefeld für den Ersetzungstext (Alt+Z)")
95102
self.replace_input = QLineEdit()
96103
self.replace_input.setObjectName("find_replace_input")
97104
self.replace_input.setPlaceholderText("Ersetzungstext (unterstützt Regex-Gruppen \\1, \\2)...")
98105
self.replace_input.setClearButtonEnabled(True)
99106
self.replace_input.setToolTip("Ersetzungstext eingeben (bei Regex: \\1, \\2 für Capture-Groups)")
100107
self.replace_input.setAccessibleName("Ersetzungstext")
101108
self.replace_input.setAccessibleDescription("Eingabefeld für den Text, durch den Treffer ersetzt werden")
109+
self.replace_label.setBuddy(self.replace_input)
102110
self.replace_input.textChanged.connect(self._schedule_update)
103111
self.replace_input.returnPressed.connect(self.replace_current)
104112

105113
self.btn_replace = QPushButton("Ersetzen")
106114
self.btn_replace.setObjectName("replace_btn")
115+
self.btn_replace.setShortcut(QKeySequence("Alt+R"))
107116
self.btn_replace.setToolTip("Aktuellen Treffer ersetzen (Alt+R)")
108117
self.btn_replace.setAccessibleName("Treffer ersetzen")
118+
self.btn_replace.setAccessibleDescription("Ersetzt das aktuell ausgewählte Vorkommen durch den Ersetzungstext (Alt+R)")
109119
self.btn_replace.clicked.connect(self.replace_current)
110120

111121
self.btn_replace_all = QPushButton("Alle ersetzen")
112122
self.btn_replace_all.setObjectName("replace_all_btn")
123+
self.btn_replace_all.setShortcut(QKeySequence("Alt+A"))
113124
self.btn_replace_all.setToolTip("Alle Treffer im Dokument auf einmal ersetzen (Alt+A)")
114125
self.btn_replace_all.setAccessibleName("Alle Treffer ersetzen")
126+
self.btn_replace_all.setAccessibleDescription("Ersetzt alle Vorkommen im gesamten Dokument durch den Ersetzungstext (Alt+A)")
115127
self.btn_replace_all.clicked.connect(self.replace_all)
116128

117129
grid.addWidget(self.replace_label, 1, 0)
@@ -127,22 +139,25 @@ def _setup_ui(self):
127139

128140
self.cb_case = QCheckBox("Groß-/&Kleinschreibung")
129141
self.cb_case.setObjectName("find_case_checkbox")
130-
self.cb_case.setToolTip("Groß- und Kleinschreibung exakt beachten")
142+
self.cb_case.setToolTip("Groß- und Kleinschreibung exakt beachten (Alt+K)")
131143
self.cb_case.setAccessibleName("Groß- und Kleinschreibung beachten")
144+
self.cb_case.setAccessibleDescription("Unterscheidet bei der Suche strikt zwischen Groß- und Kleinschreibung (Alt+K)")
132145
self.cb_case.stateChanged.connect(self._schedule_update)
133146
opt_layout.addWidget(self.cb_case)
134147

135148
self.cb_words = QCheckBox("Ganzes &Wort")
136149
self.cb_words.setObjectName("find_words_checkbox")
137-
self.cb_words.setToolTip("Nur ganze Wörter suchen (Wortgrenzen beachten)")
150+
self.cb_words.setToolTip("Nur ganze Wörter suchen (Wortgrenzen beachten, Alt+W)")
138151
self.cb_words.setAccessibleName("Nur ganze Wörter suchen")
152+
self.cb_words.setAccessibleDescription("Findet nur Vorkommen, die als eigenständige ganze Wörter vorliegen (Alt+W)")
139153
self.cb_words.stateChanged.connect(self._schedule_update)
140154
opt_layout.addWidget(self.cb_words)
141155

142156
self.cb_regex = QCheckBox("Re&gex")
143157
self.cb_regex.setObjectName("find_regex_checkbox")
144-
self.cb_regex.setToolTip("Suchbegriff als regulären Python-Ausdruck interpretieren")
158+
self.cb_regex.setToolTip("Suchbegriff als regulären Python-Ausdruck interpretieren (Alt+G)")
145159
self.cb_regex.setAccessibleName("Regulären Ausdruck verwenden")
160+
self.cb_regex.setAccessibleDescription("Interpretiert den Suchbegriff als regulären Python-Ausdruck mit Gruppen-Unterstützung (Alt+G)")
146161
self.cb_regex.stateChanged.connect(self._schedule_update)
147162
opt_layout.addWidget(self.cb_regex)
148163

@@ -154,15 +169,18 @@ def _setup_ui(self):
154169
self.status_label = QLabel("Bereit")
155170
self.status_label.setObjectName("find_status_label")
156171
self.status_label.setAccessibleName("Suchstatus")
172+
self.status_label.setAccessibleDescription("Zeigt die Anzahl der gefundenen Treffer oder Syntaxfehler an")
157173
status_bar_layout.addWidget(self.status_label)
158174
status_bar_layout.addStretch()
159175

160176
self.btn_toggle_preview = QPushButton("Voransicht")
161177
self.btn_toggle_preview.setObjectName("toggle_preview_btn")
162178
self.btn_toggle_preview.setCheckable(True)
163179
self.btn_toggle_preview.setChecked(True)
164-
self.btn_toggle_preview.setToolTip("Voransicht der Treffer und Ersetzungen ein-/ausblenden")
180+
self.btn_toggle_preview.setShortcut(QKeySequence("Alt+V"))
181+
self.btn_toggle_preview.setToolTip("Voransicht der Treffer und Ersetzungen ein-/ausblenden (Alt+V)")
165182
self.btn_toggle_preview.setAccessibleName("Voransicht umschalten")
183+
self.btn_toggle_preview.setAccessibleDescription("Schaltet die tabellarische Voransicht aller Ersetzungen ein oder aus (Alt+V)")
166184
self.btn_toggle_preview.toggled.connect(self._on_toggle_preview)
167185
status_bar_layout.addWidget(self.btn_toggle_preview)
168186
root_layout.addLayout(status_bar_layout)
@@ -185,9 +203,10 @@ def _setup_ui(self):
185203
self.preview_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
186204
self.preview_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
187205
self.preview_table.setAccessibleName("Voransichtstabelle")
188-
self.preview_table.setAccessibleDescription("Tabelle der gefundenen Treffer mit Zeile, Spalte und Vorschau der Ersetzung")
206+
self.preview_table.setAccessibleDescription("Tabelle der gefundenen Treffer mit Zeile, Spalte und Vorschau der Ersetzung (Enter auf Zeile springt zum Treffer)")
189207
self.preview_table.itemClicked.connect(self._on_table_item_activated)
190208
self.preview_table.itemDoubleClicked.connect(self._on_table_item_activated)
209+
self.preview_table.itemActivated.connect(self._on_table_item_activated)
191210
preview_layout.addWidget(self.preview_table)
192211

193212
root_layout.addWidget(self.preview_group, 1)
@@ -199,10 +218,24 @@ def _setup_ui(self):
199218
self.btn_close.setObjectName("find_close_btn")
200219
self.btn_close.setToolTip("Suchdialog schließen (Esc)")
201220
self.btn_close.setAccessibleName("Dialog schließen")
221+
self.btn_close.setAccessibleDescription("Schließt den Suchen- und Ersetzen-Dialog und kehrt zum Editor zurück (Esc)")
202222
self.btn_close.clicked.connect(self.close)
203223
bottom_layout.addWidget(self.btn_close)
204224
root_layout.addLayout(bottom_layout)
205225

226+
# Logische Tab-Reihenfolge (Barrierefreie Tastaturnavigation)
227+
self.setTabOrder(self.search_input, self.btn_prev)
228+
self.setTabOrder(self.btn_prev, self.btn_next)
229+
self.setTabOrder(self.btn_next, self.replace_input)
230+
self.setTabOrder(self.replace_input, self.btn_replace)
231+
self.setTabOrder(self.btn_replace, self.btn_replace_all)
232+
self.setTabOrder(self.btn_replace_all, self.cb_case)
233+
self.setTabOrder(self.cb_case, self.cb_words)
234+
self.setTabOrder(self.cb_words, self.cb_regex)
235+
self.setTabOrder(self.cb_regex, self.btn_toggle_preview)
236+
self.setTabOrder(self.btn_toggle_preview, self.preview_table)
237+
self.setTabOrder(self.preview_table, self.btn_close)
238+
206239
def _get_current_editor(self) -> Optional["CodeEditor"]:
207240
if not self.main_window or not hasattr(self.main_window, "tab_widget"):
208241
return None
@@ -266,7 +299,10 @@ def _update_live_results(self):
266299
if err:
267300
editor.clearSearchHighlight()
268301
self.status_label.setText(f"Regex-Fehler: {err}")
269-
self.status_label.setStyleSheet("color: #ff6b6b; font-weight: bold;")
302+
# Barrierefreie Farbweiche für WCAG AA Kontrast (>= 4.5:1)
303+
is_dark = self.palette().color(self.backgroundRole()).lightness() < 128
304+
err_color = "#ff7b72" if is_dark else "#b91c1c"
305+
self.status_label.setStyleSheet(f"color: {err_color}; font-weight: bold;")
270306
self.preview_table.setRowCount(0)
271307
return
272308

@@ -397,6 +433,14 @@ def replace_all(self):
397433
self.main_window.status_bar.showMessage(f"{count} Vorkommen ersetzt.", 4000)
398434
self._update_live_results()
399435

436+
def eventFilter(self, watched, event):
437+
if watched == self.search_input and event.type() == event.Type.KeyPress:
438+
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
439+
if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
440+
self.find_prev()
441+
return True
442+
return super().eventFilter(watched, event)
443+
400444
def keyPressEvent(self, event):
401445
if event.key() == Qt.Key.Key_Escape:
402446
self.close()
@@ -413,4 +457,5 @@ def closeEvent(self, event):
413457
editor = self._get_current_editor()
414458
if editor:
415459
editor.clearSearchHighlight()
460+
editor.setFocus()
416461
event.accept()

ui/shortcuts_dialog.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
("Bearbeiten", "Ersetzen", "Ctrl+H", "Öffnet den Suchen- und Ersetzen-Dialog im Ersetzen-Modus"),
3535
("Bearbeiten", "Weitersuchen", "F3", "Springt zum nächsten Suchtreffer"),
3636
("Bearbeiten", "Rückwärts suchen", "Shift+F3", "Springt zum vorherigen Suchtreffer"),
37+
("Bearbeiten", "Treffer ersetzen (Dialog)", "Alt+R", "Ersetzt das aktuell ausgewählte Vorkommen"),
38+
("Bearbeiten", "Alle ersetzen (Dialog)", "Alt+A", "Ersetzt alle Vorkommen im gesamten Dokument"),
39+
("Bearbeiten", "Vorheriger Treffer (Suchfeld)", "Shift+Enter", "Springt aus dem Suchfeld zum vorherigen Treffer"),
3740
("Bearbeiten", "Gehe zu Zeile", "Ctrl+G", "Springt zu einer bestimmten Zeilennummer"),
3841
("Bearbeiten", "Einstellungen", "Ctrl+,", "Öffnet die Programmeinstellungen"),
3942
("Bearbeiten", "Plugins & Sprachen", "Ctrl+Shift+P", "Öffnet die Plugin- und Sprachverwaltung"),

0 commit comments

Comments
 (0)