diff --git a/README.md b/README.md index f2008a1..eddb3f6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/testing/reading-settings.md b/docs/testing/reading-settings.md new file mode 100644 index 0000000..3e9c79b --- /dev/null +++ b/docs/testing/reading-settings.md @@ -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. diff --git a/internal/app/flat_review.go b/internal/app/flat_review.go index ef2cd4f..17cf2a0 100644 --- a/internal/app/flat_review.go +++ b/internal/app/flat_review.go @@ -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) diff --git a/internal/app/flat_review_test.go b/internal/app/flat_review_test.go index f04998e..943ae68 100644 --- a/internal/app/flat_review_test.go +++ b/internal/app/flat_review_test.go @@ -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}, } { diff --git a/internal/app/gui_table_test.go b/internal/app/gui_table_test.go index 23414fb..6f5fde7 100644 --- a/internal/app/gui_table_test.go +++ b/internal/app/gui_table_test.go @@ -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 } diff --git a/internal/preferences/native_reading.c b/internal/preferences/native_reading.c index 74d71ac..d2e32a7 100644 --- a/internal/preferences/native_reading.c +++ b/internal/preferences/native_reading.c @@ -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(); } diff --git a/tests/native_preferences.py b/tests/native_preferences.py index a4b13d8..c8ffa41 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -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")