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:
from cwidgets.pyside6 import CButton, CLabel, CLineEdit
from cwidgets.pyqt6 import CButton, CLabel, CLineEdit
CWidgets: Code with confidence, design without limits.
7 fully NVDA-compatible custom components:
- CTextEdit
- CButton
- CLabel
- CLineEdit
- CComboBox
- CListWidget
- CMessageBox
pip install cwidgets
import cwidgets
cwidgets.widgets()
cwidgets.sections()
cwidgets.show_help() cwidgets.show_help(lang="fr") cwidgets.show_help(lang="ar")
cwidgets.show_help(lang="fr", goto="CButton") cwidgets.show_help(lang="fr", goto="CTextEdit")
cwidgets.show_help(lang="fr", goto="introduction") cwidgets.show_help(lang="fr", goto="installation")
cwidgets.ctextedit.show() # names only cwidgets.ctextedit.show_details() # names + descriptions
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.
CTextEdit is a fully NVDA-accessible multi-line edit area, based on the native Win32 RichEdit engine.
- 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.
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
- 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.
from cwidgets.pyside6 import CTextEdit
from cwidgets.pyqt6 import CTextEdit
self.editor = CTextEdit(self, accessible_name="Edit area name") layout.addWidget(self.editor)
import cwidgets cwidgets.ctextedit.show() # names only cwidgets.ctextedit.show_details() # names + descriptions
CTextEdit.api()
self.editor.setText("Hello!")
text = self.editor.toPlainText() text = self.editor.text() # alias for toPlainText()
self.editor.append("New line.")
self.editor.insertPlainText("Inserted text\n")
self.editor.insertHtml("
Hello world
") # inserts: "Hello world" self.editor.insertHtml("Line 1
Line 2") # inserts: "Line 1\nLine 2"
self.editor.clear()
self.editor.selectAll()
text = self.editor.selectedText()
self.editor.selectAll() text = self.editor.selectedText()
count = self.editor.lineCount()
self.editor.setReadOnly(True) # enable self.editor.setReadOnly(False) # disable state = self.editor.isReadOnly() # check
self.editor.setFont("Arial", 12, True, False)
self.editor.setTextColor("red") self.editor.setTextColor((255, 0, 0))
self.editor.setBackgroundColor("yellow")
self.editor.setAlignment("left") self.editor.setAlignment("center") self.editor.setAlignment("right")
"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"
(255, 0, 0) # red (0, 128, 255) # light blue
self.editor.copy() # copy selection self.editor.cut() # cut selection self.editor.paste() # paste self.editor.undo() # undo self.editor.redo() # redo
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())
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
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 is an NVDA-compatible label that replaces QLabel.
QLabel is invisible to NVDA unless linked to a buddy, and only when that buddy has focus.
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.
- 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().
from cwidgets.pyside6 import CLabel, CLineEdit
from cwidgets.pyqt6 import CLabel, CLineEdit
self.lbl = CLabel("File saved", self, prefix="status")
layout.addWidget(self.lbl)
self.edit = CLineEdit(self) self.lbl = CLabel("&Name:", self, self.edit) layout.addWidget(self.lbl) # label first → appears above layout.addWidget(self.edit)
self.lbl.setText("Processing") self.lbl.setPrefix("error")
CButton is an accessible button that replaces QPushButton.
- QPushButton only accepts the space bar — Enter and Return are ignored.
- setEnabled(False) makes the button invisible to NVDA.
- Activation via Enter, Return, Space and mouse click.
- Disabled mode: not clickable but visible to NVDA ("unavailable").
- Extended activation: Space, Enter, Return, mouse click.
- Accessible deactivation: NVDA announces "unavailable".
- API identical to QPushButton.
from cwidgets.pyside6 import CButton
from cwidgets.pyqt6 import CButton
self.btn = CButton("Save", self) self.btn.clicked.connect(self.on_click)
self.btn.setEnabled(False) self.btn.setEnabled(True)
if self.btn.isEnabled(): ...
CLineEdit is a compatible input field that replaces QLineEdit.
QLineEdit requires additional code to retrieve text on validation.
CLineEdit automates this via the validated signal.
validatedsignal: emits text on each Enter/Return.placeholderTextas parameter, announced by NVDA when the field is empty.- Title via CLabel with buddy.
from cwidgets.pyside6 import CLineEdit, CLabel
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)
self.edit = CLineEdit(self, "Cairo")
self.edit = CLineEdit(self, placeholderText="Enter your name...")
self.edit.validated.connect(self.on_validated)
def on_validated(self, text: str) -> None: print(text)
CComboBox is an accessible dropdown list that replaces QComboBox.
QComboBox activates the item during arrow key navigation — problematic for blind users.
Explicit separation between navigation (arrows) and activation (Enter/Space). NVDA accessibility maintained in disabled mode.
- Free navigation: ↑↓ without activation.
- Explicit activation: Enter, Return, Space →
validatedsignal. clearedsignal: emitted if user interacts with an empty list.- Accessible deactivation: NVDA announces "unavailable".
from cwidgets.pyside6 import CComboBox, CLabel, CMessageBox
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.")
self.combo.setEnabled(False) self.combo.setEnabled(True)
CListWidget is an accessible list that replaces QListWidget.
QListWidget activates the item immediately during navigation — an obstacle for blind users.
Separation between navigation and activation. NVDA accessibility in disabled mode.
- Free navigation: ↑↓ without activation.
- Explicit activation: Enter, Return, Space.
- Accessible deactivation: NVDA announces "unavailable".
from cwidgets.pyside6 import CListWidget, CLabel
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)
self.liste.setEnabled(False) self.liste.setEnabled(True)
self.liste.clear()
CMessageBox is an accessible dialog box that replaces QMessageBox.
QMessageBox doesn't offer automatic closing.
Addition of a timed mode with automatic closing after a delay.
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.
from cwidgets.pyside6 import CMessageBox
from cwidgets.pyqt6 import CMessageBox
CMessageBox.information(self, "Success", "File saved.") CMessageBox.information(self, "Success", "File saved.", timeout=3000)
CMessageBox.warning(self, "Warning", "Insufficient disk space.") CMessageBox.warning(self, "Warning", "Unstable connection.", timeout=4000)
CMessageBox.critical(self, "Error", "File not found.")
1 — Titles: always use CLabel with buddy
self.combo.setAccessibleName("...")
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)
- PySide6 or PyQt6
- pywin32 — only for CTextEdit (Win32 RichEdit integration)
- Windows only for CTextEdit
- 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.
Mohamed Hédi Bettaieb (Tunisia) Email: hedidouz@gmail.com Design date: May 2026
CWidgets aims to make Qt applications 100% accessible to blind developers and users, without sacrificing productivity or Qt habits.