285 lines
7.9 KiB
Go
285 lines
7.9 KiB
Go
// 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"}
|
|
}
|