267 lines
8.3 KiB
Go
267 lines
8.3 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/shortcutui"
|
|
"git.stormux.org/storm/cthulhu/internal/version"
|
|
)
|
|
|
|
const learnModeInstructions = "Entering learn mode. Press any key to hear its function. Press F1 for Cthulhu documentation, F2 for default shortcuts, or F3 for application shortcuts. Press Escape or Cthulhu H to exit."
|
|
|
|
type learnModePresenter struct {
|
|
tentacles *tentacleRuntime
|
|
ctx context.Context
|
|
logger *slog.Logger
|
|
speech speechEnqueuer
|
|
braille interface{ EnqueueMessage(string) bool }
|
|
live *liveSettings
|
|
browse interface{ BrowseNavigationEnabled() bool }
|
|
active bool
|
|
lastChord input.CommandChord
|
|
lastTime uint32
|
|
}
|
|
|
|
func (presenter *learnModePresenter) present(text, cause string) {
|
|
presenter.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: cause})
|
|
presenter.braille.EnqueueMessage(text)
|
|
}
|
|
|
|
func (presenter *learnModePresenter) handle(event input.CommandEvent) {
|
|
switch event.Command {
|
|
case input.ToggleLearnMode:
|
|
entering := event.LearnModeStarted
|
|
if entering {
|
|
presenter.active = true
|
|
presenter.lastTime = 0
|
|
presenter.speech.Enqueue(speechRequest{Text: version.String(), Role: "system", Cause: "learn-mode"})
|
|
presenter.speech.Enqueue(speechRequest{Text: learnModeInstructions, Role: "system", Cause: "learn-mode"})
|
|
presenter.braille.EnqueueMessage("Learn mode. Press Escape or Cthulhu H to exit.")
|
|
return
|
|
}
|
|
presenter.key(event)
|
|
presenter.stop()
|
|
case input.LearnKey:
|
|
presenter.key(event)
|
|
case input.LearnDocumentation:
|
|
presenter.key(event)
|
|
presenter.stop()
|
|
go presenter.openDocumentation()
|
|
case input.LearnDefaultShortcuts:
|
|
presenter.key(event)
|
|
presenter.stop()
|
|
presenter.openShortcutList()
|
|
case input.LearnApplicationShortcuts:
|
|
presenter.key(event)
|
|
presenter.stop()
|
|
presenter.present("No application-specific screen reader shortcuts are available.", "learn-mode")
|
|
}
|
|
}
|
|
|
|
func (presenter *learnModePresenter) stop() {
|
|
presenter.active = false
|
|
presenter.lastTime = 0
|
|
presenter.present("Exiting learn mode.", "learn-mode")
|
|
}
|
|
|
|
func (presenter *learnModePresenter) key(event input.CommandEvent) {
|
|
if !presenter.active {
|
|
return
|
|
}
|
|
name := spokenLearnKey(event)
|
|
presenter.present(name, "learn-key")
|
|
settings := presenter.live.Load()
|
|
browse := presenter.browse != nil && presenter.browse.BrowseNavigationEnabled()
|
|
bindings, err := input.ResolveCommandBindings(input.KeyboardLayout(settings.KeyboardLayout), browse, settings.KeyBindings)
|
|
if err == nil {
|
|
if command := bindings[event.Chord]; command != "" {
|
|
presenter.present(learnCommandDescription(command), "learn-command")
|
|
presenter.lastTime = 0
|
|
return
|
|
}
|
|
}
|
|
if description := presenter.tentacles.Describe(event.Chord, input.KeyboardLayout(settings.KeyboardLayout), browse); description != "" {
|
|
presenter.present(description, "learn-command")
|
|
presenter.lastTime = 0
|
|
return
|
|
}
|
|
if event.Command == input.LearnKey && len(event.KeyName) == 1 && unicode.IsLetter(rune(event.KeyName[0])) &&
|
|
presenter.lastChord == event.Chord && event.Timestamp >= presenter.lastTime && event.Timestamp-presenter.lastTime <= 500 {
|
|
if word := phoneticLetter(rune(event.KeyName[0])); word != "" {
|
|
presenter.present(word, "learn-phonetic")
|
|
}
|
|
presenter.lastTime = 0
|
|
return
|
|
}
|
|
presenter.lastChord, presenter.lastTime = event.Chord, event.Timestamp
|
|
}
|
|
|
|
func spokenLearnKey(event input.CommandEvent) string {
|
|
name := event.KeyName
|
|
if name == "" {
|
|
name = input.FormatCommandChord(input.CommandChord{Symbol: event.Chord.Symbol})
|
|
}
|
|
switch name {
|
|
case "space":
|
|
name = "space"
|
|
case "Return":
|
|
name = "enter"
|
|
case "Escape":
|
|
name = "escape"
|
|
case "BackSpace":
|
|
name = "backspace"
|
|
case "Prior":
|
|
name = "page up"
|
|
case "Next":
|
|
name = "page down"
|
|
case "Caps_Lock":
|
|
name = "caps lock"
|
|
}
|
|
name = strings.ReplaceAll(name, "_", " ")
|
|
var parts []string
|
|
if event.Chord.CthulhuModifier {
|
|
parts = append(parts, "Cthulhu")
|
|
}
|
|
if event.Chord.Modifiers&4 != 0 {
|
|
parts = append(parts, "control")
|
|
}
|
|
if event.Chord.Modifiers&8 != 0 {
|
|
parts = append(parts, "alt")
|
|
}
|
|
if event.Chord.Modifiers&64 != 0 {
|
|
parts = append(parts, "super")
|
|
}
|
|
if event.Chord.Modifiers&1 != 0 {
|
|
parts = append(parts, "shift")
|
|
}
|
|
return strings.Join(append(parts, name), " ")
|
|
}
|
|
|
|
func learnCommandDescription(command input.Command) string {
|
|
if _, ok := input.LookupDocumentList(command); ok {
|
|
return input.CommandLabel(command)
|
|
}
|
|
switch command {
|
|
case input.EmojiMenu:
|
|
return "Open the Emoji Menu"
|
|
case input.ShowActions:
|
|
return "Show the actions available for the current object"
|
|
case input.ToggleLearnMode:
|
|
return "Enter or exit learn mode."
|
|
case input.DisableTentacles:
|
|
return "Disable all tentacles until Cthulhu restarts."
|
|
case input.Quit:
|
|
return "Quit Cthulhu."
|
|
case input.CycleProgressBarOutput:
|
|
return "Cycle progress bar speech, beeps, speech and beeps, and off."
|
|
case input.CycleSpeechMode:
|
|
return "Cycle speech, beeps, and off."
|
|
case input.Preferences:
|
|
return "Open Cthulhu preferences."
|
|
case input.ApplicationPreferences:
|
|
return "Open preferences for the current application."
|
|
case input.ChooseApplicationPreferences:
|
|
return "Choose an application and open its preferences."
|
|
case input.WhereAmI:
|
|
return "Speak details of the current item."
|
|
case input.PresentCurrentLine:
|
|
return "Read the current line. Press twice to spell it, or three times for phonetics."
|
|
case input.PresentWindowTitle:
|
|
return "Read the current window title."
|
|
case input.PresentCellFormula:
|
|
return "Present the formula for the current cell."
|
|
case input.PresentCharacterAttributes:
|
|
return "Read the current character's font and formatting attributes."
|
|
case input.ReadAll:
|
|
return "Read all from the current position."
|
|
}
|
|
return input.CommandLabel(command) + "."
|
|
}
|
|
|
|
func (presenter *learnModePresenter) openDocumentation() {
|
|
ctx, cancel := context.WithTimeout(presenter.ctx, 8*time.Second)
|
|
defer cancel()
|
|
for _, tool := range []string{"gio", "xdg-open"} {
|
|
arguments := []string{"help:cthulhu"}
|
|
if tool == "gio" {
|
|
arguments = append([]string{"open"}, arguments...)
|
|
}
|
|
if err := exec.CommandContext(ctx, tool, arguments...).Run(); err == nil {
|
|
return
|
|
}
|
|
}
|
|
presenter.logger.Warn("could not open Cthulhu documentation")
|
|
presenter.present("Help is not available.", "learn-mode")
|
|
}
|
|
|
|
func (presenter *learnModePresenter) openShortcutList() {
|
|
settings := presenter.live.Load()
|
|
list := makeShortcutList(settings)
|
|
if rows := presenter.tentacles.ShortcutRows(input.KeyboardLayout(settings.KeyboardLayout)); len(rows) > 0 {
|
|
list.Rows = append(list.Rows, rows...)
|
|
list.Title = "Screen reader shortcuts, including tentacles"
|
|
}
|
|
if len(list.Rows) == 0 {
|
|
presenter.present(list.Title, "learn-mode")
|
|
return
|
|
}
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
presenter.present("Shortcut list is not available.", "learn-mode")
|
|
return
|
|
}
|
|
cmd := exec.CommandContext(presenter.ctx, executable, "--shortcut-list-ui")
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
presenter.present("Shortcut list is not available.", "learn-mode")
|
|
return
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
presenter.logger.Warn("start shortcut list", "error", err)
|
|
presenter.present("Shortcut list is not available.", "learn-mode")
|
|
return
|
|
}
|
|
go func() {
|
|
encoder := json.NewEncoder(stdin)
|
|
encodeError := encoder.Encode(list)
|
|
stdin.Close()
|
|
waitError := cmd.Wait()
|
|
if encodeError != nil {
|
|
presenter.logger.Warn("send shortcut list", "error", encodeError)
|
|
}
|
|
if waitError != nil && presenter.ctx.Err() == nil {
|
|
presenter.logger.Warn("shortcut list window", "error", waitError)
|
|
presenter.present("Shortcut list is not available.", "learn-mode")
|
|
}
|
|
}()
|
|
}
|
|
|
|
func makeShortcutList(settings config.Settings) shortcutui.List {
|
|
rows := input.BindingRows(input.KeyboardLayout(settings.KeyboardLayout), settings.KeyBindings)
|
|
list := shortcutui.List{}
|
|
shortcuts := 0
|
|
for _, row := range rows {
|
|
if row.Keys == "" {
|
|
continue
|
|
}
|
|
group := "Default"
|
|
if row.Scope == "browse" {
|
|
group = "Browse mode"
|
|
}
|
|
list.Rows = append(list.Rows, shortcutui.Row{Group: group, Label: learnCommandDescription(row.Command), Keys: row.Keys})
|
|
shortcuts += len(strings.Split(row.Keys, ";"))
|
|
}
|
|
list.Title = fmt.Sprintf("%d screen reader default shortcuts found", shortcuts)
|
|
return list
|
|
}
|