Accessible custom widgets for PyQt6 and PySide6 — keyboard navigation, NVDA screen reader support, and Win32 RichEdit integration
Project description
User Guide - CWidgets Library V0.1.3.post1
Introduction
CWidgets is a specialized Python library that gives blind developers full control over PyQt6 and PySide6 interfaces.
The era of limitations with QTextEdit is over — you can now design interfaces containing multi-line edit areas with complete freedom and full compatibility with NVDA.
All you need to do is replace the first letter (C instead of Q):
- CTextEdit instead of QTextEdit
- CButton instead of QPushButton
- Where C stands for Custom
The library supports both Qt environments:
PySide6
from cwidgets.pyside6 import CButton, CLabel, CLineEdit
PyQt6
from cwidgets.pyqt6 import CButton, CLabel, CLineEdit
CWidgets: Code with confidence, design without limits.
Available Components
7 fully NVDA-compatible custom components:
- CTextEdit
- CButton
- CLabel
- CLineEdit
- CComboBox
- CListWidget
- CMessageBox
Installation
pip install cwidgets
Accessing Help
import cwidgets
List of all available components
cwidgets.widgets()
List of all available sections
cwidgets.sections()
Open the complete guide in the browser
cwidgets.show_help() cwidgets.show_help(lang="fr") cwidgets.show_help(lang="ar")
Open a specific component directly
cwidgets.show_help(lang="fr", goto="CButton") cwidgets.show_help(lang="fr", goto="CTextEdit")
Open a specific section directly
cwidgets.show_help(lang="fr", goto="introduction") cwidgets.show_help(lang="fr", goto="installation")
CTextEdit API — display from terminal
cwidgets.ctextedit.show() # names only cwidgets.ctextedit.show_details() # names + descriptions
Common Issues Solved
CTextEdit — Multi-line edit area: resolves QTextEdit's incompatibility with NVDA.
CComboBox & CListWidget — Separation of navigation/activation: prevents unintentional activations during arrow key navigation. NVDA accessibility maintained even in disabled mode.
CButton — Activation via Enter, Return, Space and click. NVDA compatibility even when disabled.
CLabel — Enhanced compatibility for titles and labels.
CLineEdit — Text retrieval via Enter without additional code.
CMessageBox — Auto-closing dialog boxes with configurable delay.
With the exception of CTextEdit, all components inherit from Qt and retain all their original properties and functions.
Component Details
CTextEdit
Definition
CTextEdit is a fully NVDA-accessible multi-line edit area, based on the native Win32 RichEdit engine.
Why — The Fundamental Problem
- Qt's native QTextEdit is incompatible with NVDA.
- Blind developers cannot read or write in this element.
- Until this library appeared, no known solution existed.
Solution — Win32 RichEdit Integrated into Qt
The solution directly integrates a Win32 RichEdit control into the Qt window. Win32 RichEdit is natively compatible with NVDA.
Internal structure:
- CTextEdit — Public Qt interface
- EditorStyle — Styles, font, color, alignment
- Win32 RichEdit — NVDA-compatible native engine
Features
- Full NVDA accessibility: reading, writing, navigation, selection.
- Extended API: 25 public methods available.
- Formatted text: font, size, bold, italic, color, alignment.
- Signals: textChanged, selectionChanged, cursorPositionChanged.
- Asynchronous management: styles and text queued before initialization.
- Smart focus: automatically restored after Alt+Tab.
Import
PySide6
from cwidgets.pyside6 import CTextEdit
PyQt6
from cwidgets.pyqt6 import CTextEdit
Creation
self.editor = CTextEdit(self, accessible_name="Edit area name") layout.addWidget(self.editor)
Available API
Display all methods from terminal
import cwidgets cwidgets.ctextedit.show() # names only cwidgets.ctextedit.show_details() # names + descriptions
From code
CTextEdit.api()
Content
Set content
self.editor.setText("Hello!")
Get content
text = self.editor.toPlainText() text = self.editor.text() # alias for toPlainText()
Add text at the end
self.editor.append("New line.")
Insert at cursor position
self.editor.insertPlainText("Inserted text\n")
Insert HTML at cursor position
Tags are removed,
and
become line breaks
self.editor.insertHtml("
Hello world
") # inserts: "Hello world" self.editor.insertHtml("Line 1
Line 2") # inserts: "Line 1\nLine 2"
Clear content
self.editor.clear()
Selection
Select all
self.editor.selectAll()
Get selected text (returns "" if no selection)
text = self.editor.selectedText()
Combined example — get all text
self.editor.selectAll() text = self.editor.selectedText()
Properties
Line count
count = self.editor.lineCount()
Read-only
self.editor.setReadOnly(True) # enable self.editor.setReadOnly(False) # disable state = self.editor.isReadOnly() # check
Formatting
Font — QFont or (name, size, bold, italic)
self.editor.setFont("Arial", 12, True, False)
Text color — name or (R, G, B)
self.editor.setTextColor("red") self.editor.setTextColor((255, 0, 0))
Background color
self.editor.setBackgroundColor("yellow")
Alignment
self.editor.setAlignment("left") self.editor.setAlignment("center") self.editor.setAlignment("right")
Available Colors
Supported names
"black", "white", "red", "green", "blue", "yellow", "cyan", "magenta", "gray", "darkgray", "lightgray", "orange", "purple", "violet", "pink", "brown", "navy", "teal", "lime", "olive", "maroon", "coral", "salmon", "gold", "silver"
Or RGB
(255, 0, 0) # red (0, 128, 255) # light blue
Clipboard
Shortcuts Ctrl+C/X/V/Z/Y work natively via keyboard.
These methods allow programmatic use (e.g., via a button).
self.editor.copy() # copy selection self.editor.cut() # cut selection self.editor.paste() # paste self.editor.undo() # undo self.editor.redo() # redo
Signals
React to real-time changes
self.editor.textChanged.connect(self.on_text_changed) self.editor.cursorPositionChanged.connect(self.on_cursor_changed) self.editor.selectionChanged.connect(self.on_selection_changed)
def on_text_changed(self): print(self.editor.toPlainText())
def on_cursor_changed(self): print("Cursor moved")
def on_selection_changed(self): print(self.editor.selectedText())
Behavior in Layouts
CTextEdit has a minimum size of 50x50 pixels to ensure visibility.
Case 1 — QVBoxLayout (editor alone on its line) layout.addWidget(self.editor)
Case 2 — QHBoxLayout shared with QListWidget or QComboBox
Without stretch=1, other widgets take all space.
layout = QHBoxLayout()
layout.addWidget(self.list_widget, 1)
layout.addWidget(self.editor, 1)
Case 3 — Fixed dimensions self.editor = CTextEdit(self, width=400, height=200)
Case 4 — Hide / Show self.editor.hide() # hides without destroying content self.editor.show() # redisplays with content intact
Optional Visual Title
accessible_name is read by NVDA but not visually visible. To add a visual title, use CLabel before the editor in the layout:
self.editor = CTextEdit(self) self.lbl = CLabel("Edit area:", self, self.editor) layout.addWidget(self.lbl) layout.addWidget(self.editor)
CLabel
Definition
CLabel is an NVDA-compatible label that replaces QLabel.
Why?
QLabel is invisible to NVDA unless linked to a buddy, and only when that buddy has focus.
Solution
Simple mode (individual) :
NVDA-compatible, recognized directly via tab navigation (Strong focus).
Buddy mode :
Linked to another component, invisible to tab navigation (no focus) to avoid cluttering the reading.
Its cleaned text is automatically read when the linked component takes focus.
Features
- Shortcut cleanup :
&Namebecomes"Name"for NVDA. The visual shortcut remains preserved for the system. - prefix : Additional text to express a status (e.g., "error", "status").
- Automatic synchronization via
setText()andsetPrefix().
Usage
PySide6
from cwidgets.pyside6 import CLabel, CLineEdit
PyQt6
from cwidgets.pyqt6 import CLabel, CLineEdit
Simple mode (individual)
self.lbl = CLabel("File saved", self, prefix="status")
NVDA announces: "status file saved"
layout.addWidget(self.lbl)
Buddy mode — add the label ABOVE the buddy in the layout
self.edit = CLineEdit(self) self.lbl = CLabel("&Name:", self, self.edit) layout.addWidget(self.lbl) # label first → appears above layout.addWidget(self.edit)
Dynamic update
self.lbl.setText("Processing") self.lbl.setPrefix("error")
CButton
Definition
CButton is an accessible button that replaces QPushButton.
Why?
- QPushButton only accepts the space bar — Enter and Return are ignored.
- setEnabled(False) makes the button invisible to NVDA.
Solution
- Activation via Enter, Return, Space and mouse click.
- Disabled mode: not clickable but visible to NVDA ("unavailable").
Features
- Extended activation: Space, Enter, Return, mouse click.
- Accessible deactivation: NVDA announces "unavailable".
- API identical to QPushButton.
Usage
PySide6
from cwidgets.pyside6 import CButton
PyQt6
from cwidgets.pyqt6 import CButton
self.btn = CButton("Save", self) self.btn.clicked.connect(self.on_click)
Disable / re-enable
self.btn.setEnabled(False) self.btn.setEnabled(True)
Check state
if self.btn.isEnabled(): ...
CLineEdit
Definition
CLineEdit is a compatible input field that replaces QLineEdit.
Why?
QLineEdit requires additional code to retrieve text on validation.
CLineEdit automates this via the validated signal.
Features
validatedsignal: emits text on each Enter/Return.placeholderTextas parameter, announced by NVDA when the field is empty.- Title via CLabel with buddy.
Usage
PySide6
from cwidgets.pyside6 import CLineEdit, CLabel
PyQt6
from cwidgets.pyqt6 import CLineEdit, CLabel
self.edit = CLineEdit(self) self.lbl = CLabel("Name:", self, self.edit) layout.addWidget(self.lbl) layout.addWidget(self.edit)
With initial text
self.edit = CLineEdit(self, "Cairo")
With placeholder
self.edit = CLineEdit(self, placeholderText="Enter your name...")
validated signal
self.edit.validated.connect(self.on_validated)
def on_validated(self, text: str) -> None: print(text)
CComboBox
Definition
CComboBox is an accessible dropdown list that replaces QComboBox.
Why?
QComboBox activates the item during arrow key navigation — problematic for blind users.
Solution
Explicit separation between navigation (arrows) and activation (Enter/Space). NVDA accessibility maintained in disabled mode.
Features
- Free navigation: ↑↓ without activation.
- Explicit activation: Enter, Return, Space →
validatedsignal. clearedsignal: emitted if user interacts with an empty list.- Accessible deactivation: NVDA announces "unavailable".
Usage
PySide6
from cwidgets.pyside6 import CComboBox, CLabel, CMessageBox
PyQt6
from cwidgets.pyqt6 import CComboBox, CLabel, CMessageBox
self.combo = CComboBox(self) self.combo.addItems(["Egypt", "Tunisia", "Morocco"]) self.lbl = CLabel("Country:", self, self.combo) layout.addWidget(self.lbl) layout.addWidget(self.combo)
self.combo.validated.connect(self.on_selection) self.combo.cleared.connect(self.on_cleared)
def on_selection(self) -> None: text = self.combo.currentText() index = self.combo.currentIndex() CMessageBox.information(self, "Selection", f"Country: {text}")
def on_cleared(self) -> None: CMessageBox.warning(self, "Warning", "Empty list.")
Disable / re-enable
self.combo.setEnabled(False) self.combo.setEnabled(True)
CListWidget
Definition
CListWidget is an accessible list that replaces QListWidget.
Why?
QListWidget activates the item immediately during navigation — an obstacle for blind users.
Solution
Separation between navigation and activation. NVDA accessibility in disabled mode.
Features
- Free navigation: ↑↓ without activation.
- Explicit activation: Enter, Return, Space.
- Accessible deactivation: NVDA announces "unavailable".
Usage
PySide6
from cwidgets.pyside6 import CListWidget, CLabel
PyQt6
from cwidgets.pyqt6 import CListWidget, CLabel
self.liste = CListWidget(self) self.liste.addItems(["Iraq", "Saudi Arabia", "Kuwait"]) self.lbl = CLabel("Country:", self, self.liste) layout.addWidget(self.lbl) layout.addWidget(self.liste)
self.liste.itemActivated.connect(self.on_item)
def on_item(self, item) -> None: text = item.text() row = self.liste.currentRow() print(row, text)
Disable / re-enable
self.liste.setEnabled(False) self.liste.setEnabled(True)
Clear
self.liste.clear()
CMessageBox
Definition
CMessageBox is an accessible dialog box that replaces QMessageBox.
Why?
QMessageBox doesn't offer automatic closing.
Solution
Addition of a timed mode with automatic closing after a delay.
Features
information: timed or not, without sound.warning: timed or not, without sound.critical: always non-timed + system sound, manual closing required.- Manual closing always possible before timeout ends.
Usage
PySide6
from cwidgets.pyside6 import CMessageBox
PyQt6
from cwidgets.pyqt6 import CMessageBox
Information
CMessageBox.information(self, "Success", "File saved.") CMessageBox.information(self, "Success", "File saved.", timeout=3000)
Warning
CMessageBox.warning(self, "Warning", "Insufficient disk space.") CMessageBox.warning(self, "Warning", "Unstable connection.", timeout=4000)
Error — manual closing + sound
CMessageBox.critical(self, "Error", "File not found.")
Best Practices
1 — Titles: always use CLabel with buddy
Not recommended
self.combo.setAccessibleName("...")
Correct
self.lbl = CLabel("Country:", self, self.combo)
2 — Disabling: setEnabled(False) available on all C* components.
3 — Activation
- CLineEdit and CComboBox →
validatedsignal - CListWidget →
itemActivatedsignal
4 — Creation order with buddy: always create the element before its CLabel. self.combo = CComboBox(self) self.lbl = CLabel("Country:", self, self.combo)
System Requirements
- PySide6 or PyQt6
- pywin32 — only for CTextEdit (Win32 RichEdit integration)
- Windows only for CTextEdit
Limitations
- CTextEdit: Windows only (depends on Win32 RichEdit).
- CComboBox and CListWidget:
setAccessibleNamenot recommended — replaces the CLabel buddy. - CButton: gray shadow via stylesheet — Windows doesn't automatically shadow the button when kept active for NVDA.
Developer
Mohamed Hédi Bettaieb (Tunisia) Email: hedidouz@gmail.com Design date: May 2026
Conclusion
CWidgets aims to make Qt applications 100% accessible to blind developers and users, without sacrificing productivity or Qt habits.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cwidgets-0.1.3.post1.tar.gz.
File metadata
- Download URL: cwidgets-0.1.3.post1.tar.gz
- Upload date:
- Size: 65.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
98c92d7f6eabcc687b7c6fc649a1628e225ebe3d6e099a4396fc68db61328aac
|
|
| MD5 |
019e851187210d4ed408cd8c3e9593d4
|
|
| BLAKE2b-256 |
7fed0641513d6959ec0d4fac6023f191e8f13d8f3929f32b64ed85a75d7214dc
|
File details
Details for the file cwidgets-0.1.3.post1-py3-none-any.whl.
File metadata
- Download URL: cwidgets-0.1.3.post1-py3-none-any.whl
- Upload date:
- Size: 72.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5fdb65d0b1430d0344a56b39a8cf1af143a35bc904666fa95d30ef213c5ed041
|
|
| MD5 |
155be4f0d1b51664dd0dda8ab4cfc3c2
|
|
| BLAKE2b-256 |
576f69b1c4a6051e2f41ff8e4ef39cffa6b7d41e84a250e5ef4b8d5ea9cc7026
|