Files

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
}