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 }