260 lines
8.1 KiB
Go
260 lines
8.1 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, Cthulhu H, or Cthulhu F1 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.Interrupt()
|
|
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, Cthulhu H, or Cthulhu F1 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
|
|
}
|
|
presenter.speech.Interrupt()
|
|
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 {
|
|
switch command {
|
|
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.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
|
|
}
|