Share quick settings menus and validated voice discovery

This commit is contained in:
Storm Dragon committed 2026-11-15 12:28:29 -05:00
1 parent e7bd1d0043
commit 5ca137c879
5 files changed
+671 -28

No files matched your search

+284
View File
@@ -0,0 +1,284 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
// Package quicksettings implements the reader-independent quick settings
// menu: categories of entries, a remembered position in each category,
// value stepping and the spoken announcement. Readers own their settings:
// they describe entries, report current values and apply each change.
package quicksettings
import (
"strconv"
"git.stormux.org/storm/screenreader-common/internalui/message"
)
type Messages = message.Messages
type Kind uint8
const (
// Toggle is announced as checked or unchecked; either step flips it.
Toggle Kind = iota + 1
// Choice steps through Options and wraps at both ends.
Choice
// Range steps by Step and stops at Min and Max.
Range
)
type Option struct{ ID, Label string }
// Entry describes one setting. Label and option labels are English message
// IDs passed through the menu's translator.
type Entry struct {
ID string
Label string
Kind Kind
// Range limits and step size.
Min, Max, Step int
// EmptyIsDefault announces a Choice whose selected ID is empty as
// "<label> default", such as an unset speech module or voice.
EmptyIsDefault bool
}
type Category struct {
ID, Label string
Entries []Entry
}
// Value holds an entry's value; only the field matching its Kind is used.
type Value struct {
Checked bool
Choice string
Number int
}
// Host reports current values and the options of Choice entries. The menu
// calls it synchronously from Handle.
type Host interface {
Value(Entry) Value
Options(Entry) []Option
}
type Command uint8
const (
CurrentEntry Command = iota + 1
CurrentValue
NextEntry
PreviousEntry
NextValue
PreviousValue
NextCategory
PreviousCategory
)
// Result is one handled command. When Changed is true the host applies
// Value to Entry before speaking Text; an asynchronous host speaks Text when
// the change succeeds. Available is false for an empty menu or an entry
// without options, and Text then explains why.
type Result struct {
Entry Entry
Value Value
Changed bool
Available bool
Text string
}
// Menu is not safe for concurrent use. Copy it with Clone.
type Menu struct {
Messages Messages
categories []Category
category int
positions []int
}
// New drops categories without entries.
func New(categories []Category, messages Messages) *Menu {
menu := &Menu{Messages: messages}
menu.Replace(categories)
return menu
}
// IsValueChange reports whether command changes the selected entry's value.
func IsValueChange(command Command) bool { return command == NextValue || command == PreviousValue }
// Replace installs a new entry set, keeping the selected category and each
// category's selected entry when their IDs are still present. kept is false
// when the previously selected entry no longer exists; hosts should then not
// apply a value change to whichever entry is selected instead.
func (m *Menu) Replace(categories []Category) (kept bool) {
var selectedCategory, selectedEntry string
if entry, ok := m.Selected(); ok {
selectedEntry = entry.ID
}
selectedEntries := make(map[string]string)
for index, category := range m.categories {
if index == m.category {
selectedCategory = category.ID
}
selectedEntries[category.ID] = category.Entries[m.positions[index]].ID
}
m.categories, m.positions, m.category = nil, nil, 0
for _, category := range categories {
if len(category.Entries) == 0 {
continue
}
category.Entries = append([]Entry(nil), category.Entries...)
position := 0
for index, entry := range category.Entries {
if entry.ID == selectedEntries[category.ID] {
position = index
break
}
}
if category.ID == selectedCategory {
m.category = len(m.categories)
}
m.categories = append(m.categories, category)
m.positions = append(m.positions, position)
}
entry, ok := m.Selected()
return selectedEntry == "" || ok && entry.ID == selectedEntry
}
func (m *Menu) Clone() *Menu {
clone := *m
clone.categories = append([]Category(nil), m.categories...)
clone.positions = append([]int(nil), m.positions...)
return &clone
}
func (m *Menu) Categories() []Category {
categories := append([]Category(nil), m.categories...)
for index := range categories {
categories[index].Entries = append([]Entry(nil), categories[index].Entries...)
}
return categories
}
// Selected reports the current entry; ok is false for an empty menu.
func (m *Menu) Selected() (entry Entry, ok bool) {
if len(m.categories) == 0 {
return Entry{}, false
}
return m.categories[m.category].Entries[m.positions[m.category]], true
}
func (m *Menu) Handle(command Command, host Host) Result {
if len(m.categories) == 0 {
return Result{Text: m.Messages.T("Quick menu not available")}
}
categoryChanged := command == NextCategory || command == PreviousCategory
switch command {
case NextCategory:
m.category = (m.category + 1) % len(m.categories)
case PreviousCategory:
m.category = (m.category + len(m.categories) - 1) % len(m.categories)
}
entries := m.categories[m.category].Entries
position := &m.positions[m.category]
switch command {
case NextEntry:
*position = (*position + 1) % len(entries)
case PreviousEntry:
*position = (*position + len(entries) - 1) % len(entries)
}
entry := entries[*position]
result := Result{Entry: entry, Value: host.Value(entry), Available: true}
step := 0
switch command {
case NextValue:
step = 1
case PreviousValue:
step = -1
}
var options []Option
switch entry.Kind {
case Toggle:
if step != 0 {
result.Value.Checked = !result.Value.Checked
result.Changed = true
}
case Choice:
options = host.Options(entry)
if len(options) == 0 {
result.Available = false
result.Text = m.Messages.Format("{0} not available", m.Messages.T(entry.Label))
if categoryChanged {
result.Text = m.Messages.T(m.categories[m.category].Label) + ". " + result.Text
}
return result
}
if step != 0 {
index := -1
for candidate, option := range options {
if option.ID == result.Value.Choice {
index = candidate
break
}
}
if index < 0 {
if step > 0 {
index = 0
} else {
index = len(options) - 1
}
} else {
index = (index + step + len(options)) % len(options)
}
result.Changed = options[index].ID != result.Value.Choice
result.Value.Choice = options[index].ID
}
case Range:
if step != 0 {
next := max(entry.Min, min(entry.Max, result.Value.Number+step*entry.Step))
result.Changed = next != result.Value.Number
result.Value.Number = next
}
}
text, labelled := m.describe(entry, result.Value, options)
if !labelled && (categoryChanged || command == CurrentEntry || command == NextEntry || command == PreviousEntry) {
text = m.Messages.T(entry.Label) + " " + text
}
if categoryChanged {
text = m.Messages.T(m.categories[m.category].Label) + ". " + text
}
result.Text = text
return result
}
// Describe returns the value announcement used after a value change.
func (m *Menu) Describe(entry Entry, value Value, options []Option) string {
text, _ := m.describe(entry, value, options)
return text
}
func (m *Menu) describe(entry Entry, value Value, options []Option) (text string, labelled bool) {
switch entry.Kind {
case Toggle:
if value.Checked {
return m.Messages.T("checked"), false
}
return m.Messages.T("unchecked"), false
case Choice:
if entry.EmptyIsDefault && value.Choice == "" {
return m.Messages.T(entry.Label) + " " + m.Messages.T("default"), true
}
for _, option := range options {
if option.ID == value.Choice {
return m.Messages.T(option.Label), false
}
}
if len(options) > 0 {
return m.Messages.Format("{0} not available", value.Choice), false
}
return value.Choice, false
}
return strconv.Itoa(value.Number), false
}
// MessageIDs lists the menu's own English messages for translation catalogs.
func MessageIDs() []string {
return []string{"Quick menu not available", "{0} not available", "checked", "unchecked", "default"}
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package quicksettings
import (
"strings"
"testing"
)
type fakeHost struct {
values map[string]Value
options map[string][]Option
}
func (h *fakeHost) Value(entry Entry) Value { return h.values[entry.ID] }
func (h *fakeHost) Options(entry Entry) []Option { return h.options[entry.ID] }
func (h *fakeHost) apply(result Result) { h.values[result.Entry.ID] = result.Value }
func (h *fakeHost) run(m *Menu, command Command) Result {
result := m.Handle(command, h)
if result.Changed {
h.apply(result)
}
return result
}
func fixture() (*Menu, *fakeHost) {
menu := New([]Category{
{ID: "speech", Label: "Speech", Entries: []Entry{
{ID: "rate", Label: "speech rate", Kind: Range, Min: -100, Max: 100, Step: 10},
{ID: "voice", Label: "speech voice", Kind: Choice, EmptyIsDefault: true},
}},
{ID: "empty", Label: "Empty"},
{ID: "reading", Label: "Reading", Entries: []Entry{
{ID: "punctuation", Label: "punctuation level", Kind: Choice},
{ID: "emoticons", Label: "emoticons", Kind: Toggle},
}},
}, Messages{})
host := &fakeHost{
values: map[string]Value{"rate": {Number: 80}, "punctuation": {Choice: "some"}},
options: map[string][]Option{
"voice": {{ID: "", Label: "default"}, {ID: "alice", Label: "Alice"}},
"punctuation": {{ID: "none", Label: "none"}, {ID: "some", Label: "some"}, {ID: "all", Label: "all"}},
},
}
return menu, host
}
func TestNavigationAdjustmentAndAnnouncements(t *testing.T) {
menu, host := fixture()
for _, step := range []struct {
command Command
want string
}{
{CurrentEntry, "speech rate 80"},
{NextValue, "90"},
{NextValue, "100"},
{NextValue, "100"},
{CurrentValue, "100"},
{NextEntry, "speech voice default"},
{NextValue, "Alice"},
{CurrentEntry, "speech voice Alice"},
{NextValue, "speech voice default"},
{NextEntry, "speech rate 100"},
{PreviousEntry, "speech voice default"},
{NextCategory, "Reading. punctuation level some"},
{NextValue, "all"},
{NextValue, "none"},
{PreviousValue, "all"},
{NextEntry, "emoticons unchecked"},
{NextValue, "checked"},
{PreviousValue, "unchecked"},
{NextCategory, "Speech. speech voice default"},
{PreviousCategory, "Reading. emoticons unchecked"},
} {
if got := host.run(menu, step.command).Text; got != step.want {
t.Fatalf("%v: got %q, want %q", step.command, got, step.want)
}
}
if len(menu.Categories()) != 2 {
t.Fatal("empty category was kept", menu.Categories())
}
}
func TestChangedOnlyWhenValueMoves(t *testing.T) {
menu, host := fixture()
host.values["rate"] = Value{Number: 100}
if result := menu.Handle(NextValue, host); result.Changed || !result.Available {
t.Fatal(result)
}
if result := menu.Handle(PreviousValue, host); !result.Changed || result.Value.Number != 90 {
t.Fatal(result)
}
if result := menu.Handle(CurrentValue, host); result.Changed {
t.Fatal(result)
}
}
func TestUnavailableMenuAndOptions(t *testing.T) {
menu := New(nil, Messages{})
if result := menu.Handle(NextEntry, &fakeHost{}); result.Available || result.Text != "Quick menu not available" {
t.Fatal(result)
}
menu, host := fixture()
host.options["voice"] = nil
menu.Handle(NextEntry, host)
if result := menu.Handle(NextValue, host); result.Available || result.Changed || result.Text != "speech voice not available" {
t.Fatal(result)
}
}
func TestReplaceKeepsSelectionByID(t *testing.T) {
menu, host := fixture()
menu.Handle(NextCategory, host)
menu.Handle(NextEntry, host)
categories := menu.Categories()
categories[0], categories[1] = categories[1], categories[0]
categories[0].Entries = append([]Entry{{ID: "new", Label: "new", Kind: Toggle}}, categories[0].Entries...)
if !menu.Replace(categories) {
t.Fatal("kept selection reported as removed")
}
if entry, ok := menu.Selected(); !ok || entry.ID != "emoticons" {
t.Fatal(entry, ok)
}
categories[0].Entries = categories[0].Entries[:2]
if menu.Replace(categories) {
t.Fatal("removed selection reported as kept")
}
if !IsValueChange(NextValue) || !IsValueChange(PreviousValue) || IsValueChange(NextEntry) {
t.Fatal("value change classification")
}
menu.Replace(nil)
if _, ok := menu.Selected(); ok {
t.Fatal("empty menu reported a selection")
}
}
func TestCloneIsIndependent(t *testing.T) {
menu, host := fixture()
clone := menu.Clone()
menu.Handle(NextEntry, host)
if entry, _ := clone.Selected(); entry.ID != "rate" {
t.Fatal(entry)
}
}
func TestMessagesAreTranslated(t *testing.T) {
menu, host := fixture()
menu.Messages = Messages{Translate: strings.ToUpper}
if got := menu.Handle(NextCategory, host).Text; got != "READING. PUNCTUATION LEVEL SOME" {
t.Fatal(got)
}
for _, id := range MessageIDs() {
if id == "" {
t.Fatal("empty message ID")
}
}
}
func TestMissingChoiceIsNotAnnouncedAsAnotherValue(t *testing.T) {
menu, host := fixture()
menu.Handle(NextEntry, host)
host.values["voice"] = Value{Choice: "removed"}
if got := menu.Handle(CurrentEntry, host).Text; got != "speech voice removed not available" {
t.Fatal(got)
}
if got := menu.Handle(NextValue, host); !got.Changed || got.Value.Choice != "" {
t.Fatal(got)
}
if got := menu.Handle(PreviousValue, host); !got.Changed || got.Value.Choice != "alice" {
t.Fatal(got)
}
}
func TestCategoriesCannotMutateMenuOrClone(t *testing.T) {
menu, _ := fixture()
clone := menu.Clone()
categories := menu.Categories()
categories[0].Entries[0].ID = "modified"
for _, current := range []*Menu{menu, clone} {
if entry, _ := current.Selected(); entry.ID != "rate" {
t.Fatal(entry)
}
}
}