Add a description column to the reading settings table under speech.

This commit is contained in:
Storm Dragon committed 2026-10-20 19:33:48 -04:00
1 parent ffcea2efea
commit 41283a5d8d
7 files changed
+219 -46

No files matched your search

+12
View File
@@ -130,6 +130,18 @@ changes the running reader, and saved choices survive restart and can be set
per application. Older capitalization styles are imported into the corresponding
beep or “cap” checkbox.
## Reading settings descriptions
Under Speech, the Reading settings table has three columns: Enabled, Setting
and Description. Each description explains what the checkbox changes, with
examples where useful. Use Left and Right to read individual cells and Space
to toggle the selected setting from any column. With full-row reading enabled,
moving between rows reads the state, title and description together. Further
input interrupts speech as usual.
See [reading settings verification](docs/testing/reading-settings.md) for
automated checks and live listening steps.
## Flat review spelling and phonetic hints
Press Numpad 2 twice to hear the current character phonetically. Press Numpad 5
+38
View File
@@ -0,0 +1,38 @@
# Reading settings descriptions
Build a separate test binary and run the focused checks:
```sh
go build -o /tmp/cthulhu-reading-descriptions ./cmd/cthulhu
go test ./internal/preferences ./internal/presentation -count=1
go test ./internal/app -run 'GUITable|PauseChunks' -count=1
CTHULHU_BINARY=/tmp/cthulhu-reading-descriptions CTHULHU_PREFS_READING_ONLY=1 /usr/bin/python3 tests/native_preferences.py
```
The native check uses private Xvfb, D-Bus and AT-SPI services. It checks all
three column headers, a description for every setting, checkbox states,
Left/Right and Up/Down navigation, Space from the description column,
Tab/Shift+Tab, Apply/reload and Cancel. The reader tests check full-row speech
including a third cell, individual-cell speech and interruption.
Before submission, review the diff and check the rebuilt reader in a live
session:
1. Open Preferences, select Speech and enter Reading settings. With Speak full
row in GUI tables enabled, use Up and Down and confirm the state, setting
name and explanation are spoken in that order.
2. Press another navigation key before an explanation finishes. Confirm the
earlier speech stops and the new row is announced promptly.
3. Use Left and Right to reach all three cells. Confirm the description is
read in full, without repeating the setting title or checkbox state.
4. Press Space from each column and confirm it toggles only the selected
setting. Apply, close and reopen preferences to check saved state. Change a
checkbox again and Cancel to confirm the change is discarded.
5. Check Tab and Shift+Tab into and out of the table. Disable full-row reading
and confirm arrows read individual cells. Restore the preferred setting.
6. Check the modifier-key table and an application table to confirm their
existing row and cell reading still works. Check the new descriptions on a
physical Braille display when available.
Automated checks do not establish audible speech, physical keyboard or Braille
acceptance, or behavior of the installed reader.
+14 -5
View File
@@ -64,6 +64,7 @@ func flatReviewCommand(command input.Command) (review.FlatUnit, int) {
func presentFlatReviewResult(logger *slog.Logger, speech speechEnqueuer, result review.Result) {
text := ""
boundary := false
if result.Err != nil {
logger.Warn("flat review failed", "error", result.Err)
switch {
@@ -75,16 +76,24 @@ func presentFlatReviewResult(logger *slog.Logger, speech speechEnqueuer, result
text = "Could not find current location."
}
} else if !result.Moved && (result.FlatStep != 0 || result.FlatUnit == review.WordAbove || result.FlatUnit == review.WordBelow) {
// Python's sequential review stops silently at window boundaries.
return
if result.FlatUnit != review.Line {
return
}
boundary = true
if result.FlatStep < 0 {
text = "Top of screen."
} else {
text = "Bottom of screen."
}
} else {
text = presentation.FlatReviewText(result.FlatPosition.Text, result.FlatUnit == review.Character || result.FlatUnit == review.LineEnd)
}
request := speechRequest{Text: text, Role: "text", Cause: "flat-review", Character: result.Err == nil && (result.FlatUnit == review.Character || result.FlatUnit == review.LineEnd)}
if result.Err == nil && result.FlatUnit == review.Line {
hasContent := result.Err == nil && !boundary
request := speechRequest{Text: text, Role: "text", Cause: "flat-review", Character: hasContent && (result.FlatUnit == review.Character || result.FlatUnit == review.LineEnd)}
if hasContent && result.FlatUnit == review.Line {
request.Line = &speechLine{Object: result.FlatPosition.Object, Raw: result.FlatPosition.Text}
}
if result.Err == nil {
if hasContent {
set_review_spelling(&request, result.FlatPosition.Text, result.FlatSpeech)
}
speech.Enqueue(request)
+45 -1
View File
@@ -76,6 +76,14 @@ func TestProcessEventsPresentsFlatReviewThroughSpeechQueue(t *testing.T) {
expect("Review field, text")
worker.ReadFlat(review.Line, 0)
expect("a 猫!oo")
worker.ReadFlat(review.Line, -1)
expect("Top of screen.")
worker.ReadFlat(review.Line, 0)
expect("a 猫!oo")
worker.ReadFlat(review.Line, 1)
expect("Bottom of screen.")
worker.ReadFlat(review.Line, 0)
expect("a 猫!oo")
worker.ReadFlat(review.Character, 1)
expect("space")
worker.ReadFlat(review.Character, 1)
@@ -122,11 +130,47 @@ func TestFlatReviewResultUsesNativeFocusAndRejectsOldPage(t *testing.T) {
}
}
func TestFlatReviewLineBoundaryFeedback(t *testing.T) {
for _, tc := range []struct {
name string
step int
want string
}{
{"top", -1, "Top of screen."},
{"bottom", 1, "Bottom of screen."},
} {
t.Run(tc.name, func(t *testing.T) {
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)}
result := review.Result{
FlatUnit: review.Line, FlatStep: tc.step, FlatSpeech: review.FlatSpell,
FlatPosition: review.FlatPosition{Text: " Edge line"},
}
for range 2 {
presentFlatReviewResult(slog.New(slog.DiscardHandler), queue, result)
select {
case request := <-queue.requests:
if request.Text != tc.want || request.Cause != "flat-review" {
t.Fatalf("boundary request = %+v", request)
}
if request.Line != nil || request.Character || len(request.Spelling) != 0 {
t.Fatalf("boundary retained content speech metadata: %+v", request)
}
default:
t.Fatal("boundary was silent")
}
}
})
}
}
func TestFlatReviewErrorsAndSilentBoundary(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 2)}
logger := slog.New(slog.DiscardHandler)
for _, result := range []review.Result{
{FlatUnit: review.Line, FlatStep: 1},
{FlatUnit: review.Word, FlatStep: -1},
{FlatUnit: review.Word, FlatStep: 1},
{FlatUnit: review.Character, FlatStep: -1},
{FlatUnit: review.Character, FlatStep: 1},
{FlatUnit: review.WordAbove},
{FlatUnit: review.WordBelow},
} {
+11 -3
View File
@@ -79,17 +79,25 @@ func TestGUITableNormalizedFocusRetainsOwnerAndTitle(t *testing.T) {
other.Source.BusName = ":1.99"
adapter.events <- other
expect_speech(second, 6, "checked, Break speech into chunks between pauses")
// A third description cell participates in full-row speech, while
// horizontal movement reads just that cell.
description := "Reads numbers one digit at a time."
rowText := "unchecked, Speak numbers as digits, " + description
third := cell_event("/reading/2/state", "unchecked", rowText, 2, 0, atspi.SelectionChangedEvent)
expect_speech(third, 7, rowText)
descriptionCell := cell_event("/reading/2/description", description, rowText, 2, 2, atspi.ActiveDescendantEvent)
expect_speech(descriptionCell, 8, description)
button := atspi.NewSnapshot("/preferences/apply", "Apply", "button", "", "")
button.BusName = window.BusName
focus := atspi.NewEvent(atspi.FocusEvent, button)
focus.Window = window
expect_speech(focus, 7, "Apply, button")
expect_speech(focus, 9, "Apply, button")
adapter.events <- up // A table selection after leaving the table is noise.
// Returning through a cell focus event establishes ownership and speaks
// the table name again, without changing the user's row-reading option.
first.OriginPath = first.Source.Path
expect_speech(first, 8, "Reading settings, unchecked, Only speak displayed text")
expect_speech(second, 9, "checked, Break speech into chunks between pauses")
expect_speech(first, 10, "Reading settings, unchecked, Only speak displayed text")
expect_speech(second, 11, "checked, Break speech into chunks between pauses")
cancel()
<-completed
}
+76 -35
View File
@@ -4,46 +4,80 @@
#include "native_internal.h"
static GtkListStore *reading_store;
static const struct { const char *id; const char *label; } reading_rows[] = {
{"only_speak_displayed_text", "Only speak displayed text"},
{"automatically_read_title_changes", "Automatically read title changes"},
{"automatically_read_clipboard_changes", "Automatically read clipboard changes"},
{"phonetic_character_hints", "Phonetic character hints"},
{"enable_pause_breaks", "Break speech into chunks between pauses"},
{"speak_numbers_as_digits", "Speak numbers as digits"},
{"speak_multicase_strings_as_words", "Speak multicase strings as words"},
{"enable_indentation", "Enable indentation presentation"},
{"speak_blank_lines", "Speak blank lines"},
{"enable_mnemonic_speaking", "Speak object mnemonics"},
{"enable_position_speaking", "Speak child position"},
{"enable_tutorial_messages", "Speak tutorial messages"},
{"speak_description", "Speak object descriptions"},
{"messages_are_detailed", "System messages are detailed"},
{"use_color_names", "Speak colors as names"},
{"speak_misspelled_indicator", "Speak misspelled-word indicator"},
{"speak_context_blockquote", "Announce block quotes during navigation"},
{"speak_context_list", "Announce lists during navigation"},
{"speak_context_table", "Announce tables during navigation"},
{"speak_context_panel", "Announce panels during navigation"},
{"speak_context_landmark", "Announce landmarks during navigation"},
{"speak_context_non_landmark_form", "Announce forms during navigation"},
{"read_full_row_in_gui_table", "Speak full row in GUI tables"},
{"read_full_row_in_document_table", "Speak full row in document tables"},
{"read_full_row_in_spreadsheet", "Speak full row in spreadsheets"},
{"present_tooltips", "Present tooltips"},
{"enable_mouse_review", "Speak object under mouse"},
{"say_all_context_blockquote", "Announce block quotes in Say All"},
{"say_all_context_list", "Announce lists in Say All"},
{"say_all_context_table", "Announce tables in Say All"},
{"say_all_context_panel", "Announce panels in Say All"},
{"say_all_context_non_landmark_form", "Announce forms in Say All"},
{"say_all_context_landmark", "Announce landmarks in Say All"},
static const struct { const char *id; const char *label; const char *description; } reading_rows[] = {
{"only_speak_displayed_text", "Only speak displayed text",
"Limits reading to displayed names, text and values. Omits extra information such as control types, states and container announcements."},
{"automatically_read_title_changes", "Automatically read title changes",
"Announces changes to the active window's title, such as a new page title in a browser."},
{"automatically_read_clipboard_changes", "Automatically read clipboard changes",
"Automatically reads new text when the clipboard contents change, such as after copying text."},
{"phonetic_character_hints", "Phonetic character hints",
"When navigating characters with Left or Right, adds a phonetic name after a 300 ms pause, for example, a then alpha. Further input cancels the hint."},
{"enable_pause_breaks", "Break speech into chunks between pauses",
"Separates parts of an announcement with pauses, such as between a control's name, type and state."},
{"speak_numbers_as_digits", "Speak numbers as digits",
"Reads numbers one digit at a time, for example, 123 as one two three."},
{"speak_multicase_strings_as_words", "Speak multicase strings as words",
"Separates words where a lowercase letter is followed by an uppercase letter, for example, saveFile as save File."},
{"enable_indentation", "Enable indentation presentation",
"Reports changes in a line's leading spaces and tabs using the selected indentation presentation mode."},
{"speak_blank_lines", "Speak blank lines",
"Says blank when navigating to an empty line, instead of leaving it silent."},
{"enable_mnemonic_speaking", "Speak object mnemonics",
"Announces a control's access key when the application provides one, such as Alt+O for an Open button."},
{"enable_position_speaking", "Speak child position",
"Announces an item's position within a group when available, for example, 2 of 5."},
{"enable_tutorial_messages", "Speak tutorial messages",
"Adds brief instructions for using the focused control, such as Press Space to toggle for a checkbox."},
{"speak_description", "Speak object descriptions",
"Reads additional descriptive text supplied by the application for a control or window."},
{"messages_are_detailed", "System messages are detailed",
"Reads a notification's full message when available. When disabled, reads just its summary when one is provided."},
{"use_color_names", "Speak colors as names",
"Uses color names when reporting text colors, instead of their numeric color values."},
{"speak_misspelled_indicator", "Speak misspelled-word indicator",
"Says misspelled when reading text that the application's spell checker has marked as incorrect."},
{"speak_context_blockquote", "Announce block quotes during navigation",
"Announces when you enter or leave a block quote while navigating text."},
{"speak_context_list", "Announce lists during navigation",
"Announces when you enter or leave a list while navigating."},
{"speak_context_table", "Announce tables during navigation",
"Announces when you enter or leave a data table while navigating."},
{"speak_context_panel", "Announce panels during navigation",
"Announces when you enter or leave a panel or group of controls while navigating."},
{"speak_context_landmark", "Announce landmarks during navigation",
"Announces when you enter or leave a landmark, such as a page's main content or navigation region."},
{"speak_context_non_landmark_form", "Announce forms during navigation",
"Announces when you enter or leave a form that is not identified as a landmark."},
{"read_full_row_in_gui_table", "Speak full row in GUI tables",
"Reads all cells in a row when moving between rows in application tables, including this settings table. Left and Right read individual cells."},
{"read_full_row_in_document_table", "Speak full row in document tables",
"Reads the whole row when navigating rows in a document or web page table. Moving between columns reads individual cells."},
{"read_full_row_in_spreadsheet", "Speak full row in spreadsheets",
"Reads the whole row when navigating rows in a spreadsheet. Moving between columns reads individual cells."},
{"present_tooltips", "Present tooltips",
"Reads tooltip text when the application displays it."},
{"enable_mouse_review", "Speak object under mouse",
"Reads the object under the mouse pointer as you move the mouse."},
{"say_all_context_blockquote", "Announce block quotes in Say All",
"Announces entry into and exit from block quotes during continuous reading with Say All. This is separate from navigation announcements."},
{"say_all_context_list", "Announce lists in Say All",
"Announces entry into and exit from lists during continuous reading with Say All. This is separate from navigation announcements."},
{"say_all_context_table", "Announce tables in Say All",
"Announces entry into and exit from data tables during continuous reading with Say All. This is separate from navigation announcements."},
{"say_all_context_panel", "Announce panels in Say All",
"Announces entry into and exit from panels or groups of controls during continuous reading with Say All. This is separate from navigation announcements."},
{"say_all_context_non_landmark_form", "Announce forms in Say All",
"Announces entry into and exit from forms that are not landmarks during continuous reading with Say All. This is separate from navigation announcements."},
{"say_all_context_landmark", "Announce landmarks in Say All",
"Announces entry into and exit from landmarks, such as main content or navigation regions, during continuous reading with Say All. This is separate from navigation announcements."},
};
enum {
READING_ID,
READING_LABEL,
READING_ENABLED,
READING_DESCRIPTION,
READING_N_COLUMNS
};
static void name_reading_toggle_cells(void);
@@ -94,7 +128,7 @@ static void name_reading_toggle_cells(void) {
}
void prefs_init_reading_settings(void) {
reading_store = gtk_list_store_new(READING_N_COLUMNS, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_BOOLEAN);
reading_store = gtk_list_store_new(READING_N_COLUMNS, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_BOOLEAN, G_TYPE_STRING);
for (unsigned int i = 0; i < G_N_ELEMENTS(reading_rows); i++) {
GtkTreeIter iter;
gtk_list_store_append(reading_store, &iter);
@@ -102,6 +136,7 @@ void prefs_init_reading_settings(void) {
READING_ID, reading_rows[i].id,
READING_LABEL, reading_rows[i].label,
READING_ENABLED, FALSE,
READING_DESCRIPTION, reading_rows[i].description,
-1);
}
@@ -118,6 +153,12 @@ void prefs_init_reading_settings(void) {
"Enabled", toggle_renderer, "active", READING_ENABLED, NULL);
gtk_tree_view_append_column(view, enabled_column);
gtk_tree_view_append_column(view, setting_column);
GtkCellRenderer *descriptionRenderer = gtk_cell_renderer_text_new();
g_object_set(descriptionRenderer, "wrap-mode", PANGO_WRAP_WORD_CHAR, "wrap-width", 280, NULL);
GtkTreeViewColumn *descriptionColumn = gtk_tree_view_column_new_with_attributes(
"Description", descriptionRenderer, "text", READING_DESCRIPTION, NULL);
gtk_tree_view_column_set_expand(descriptionColumn, TRUE);
gtk_tree_view_append_column(view, descriptionColumn);
g_signal_connect(view, "key-press-event", G_CALLBACK(reading_key_press), NULL);
name_reading_toggle_cells();
}
+23 -2
View File
@@ -845,11 +845,15 @@ def inside():
readingTree = next(node for node in nodes if node.name == "Reading settings"
and node.getRole() == pyatspi.ROLE_TABLE)
readingTable = readingTree.queryTable()
assert readingTable.nColumns == 2
assert readingTable.nColumns == 3
assert [readingTable.getColumnDescription(column) for column in range(3)] == [
"Enabled", "Setting", "Description"]
for row in range(readingTable.nRows):
stateCell = readingTable.getAccessibleAt(row, 0)
settingCell = readingTable.getAccessibleAt(row, 1)
descriptionCell = readingTable.getAccessibleAt(row, 2)
assert settingCell.name and settingCell.name not in ("checked", "unchecked"), settingCell.name
assert descriptionCell.name and descriptionCell.name != settingCell.name, settingCell.name
checked = stateCell.getState().contains(pyatspi.STATE_CHECKED)
assert stateCell.name == ("checked" if checked else "unchecked"), stateCell.name
titleRow = next(row for row in range(readingTable.nRows)
@@ -868,6 +872,9 @@ def inside():
if readingTable.getAccessibleAt(row, 1).name == "Phonetic character hints")
hintCell = readingTable.getAccessibleAt(hintRow, 0)
assert hintCell.name == "unchecked" and not hintCell.getState().contains(pyatspi.STATE_CHECKED)
hintDescription = readingTable.getAccessibleAt(hintRow, 2).name
assert "Left or Right" in hintDescription and "300 ms" in hintDescription, hintDescription
assert "cancels the hint" in hintDescription, hintDescription
for label, expected in (("Phonetic character hints", "checked"),
("Announce lists in Say All", "checked"),
("Present tooltips", "checked"),
@@ -888,12 +895,26 @@ def inside():
assert stateCell.getState().contains(pyatspi.STATE_CHECKED) != wasChecked
key("Right")
wait_for(lambda: readingTable.getAccessibleAt(0, 1).getState().contains(pyatspi.STATE_FOCUSED), "Right did not focus the setting label")
key("Right")
descriptionCell = readingTable.getAccessibleAt(0, 2)
wait_for(lambda: descriptionCell.getState().contains(pyatspi.STATE_FOCUSED), "Right did not focus the description")
# Space still toggles the selected row from the new text column.
key("space")
wait_for(lambda: stateCell.getState().contains(pyatspi.STATE_CHECKED) == wasChecked,
"Space in the description did not toggle the checkbox")
key("space")
wait_for(lambda: stateCell.getState().contains(pyatspi.STATE_CHECKED) != wasChecked,
"Space in the description did not restore the checkbox")
key("Down")
wait_for(lambda: readingTable.isRowSelected(1), "Down did not select the next reading setting")
key("Up")
wait_for(lambda: readingTable.isRowSelected(0), "Up did not return to the first reading setting")
key("Left")
wait_for(lambda: readingTable.getAccessibleAt(0, 1).getState().contains(pyatspi.STATE_FOCUSED), "Left did not return to the setting label")
key("Left")
wait_for(lambda: readingTable.getAccessibleAt(0, 0).getState().contains(pyatspi.STATE_FOCUSED), "Left did not focus the checkbox")
key("Right", "Right")
wait_for(lambda: descriptionCell.getState().contains(pyatspi.STATE_FOCUSED), "description focus missing before Tab")
key("Tab")
wait_for(lambda: not any(node.getState().contains(pyatspi.STATE_FOCUSED) for node in walk(readingTree)), "Tab did not leave reading settings")
key("shift+Tab")
@@ -922,7 +943,7 @@ def inside():
focus(named("Cancel")); key("space")
assert process.wait(timeout=5) == 0
assert responses.read_remaining() == "", "Cancel submitted reading changes"
print("PASS: reading table, Say All, tooltip and mouse choices, Space, arrows, Tab/Shift+Tab, Apply/reload, Cancel")
print("PASS: three-column reading table and descriptions, Say All, tooltip and mouse choices, Space, arrows, Tab/Shift+Tab, Apply/reload, Cancel")
return
if os.environ.get("CTHULHU_PREFS_MODIFIERS_ONLY") == "1":
modifierTree = named("Cthulhu modifier keys")