Share quick settings menus and validated voice discovery
This commit is contained in:
1 parent
e7bd1d0043
commit
5ca137c879
5 files changed
+671
-28
No files matched your search
@@ -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"}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user