Files

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
}