Files

187 lines
6.0 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"log/slog"
"slices"
"strconv"
"strings"
"time"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/input"
"git.stormux.org/storm/fenrir/internal/scripts"
)
// These are the complete message IDs from Python's tutorial command and
// HelpManager. Keep named fields intact until after gettext lookup.
const tutorialEntry = "Entering tutorial mode. In this mode commands are described but not executed. Use up and down to browse actions and left and right to switch between Active actions, Unbound actions, and Plugins. Press Space to repeat the current item. To exit tutorial mode press Fenrir+F1 or Escape. Active actions."
const tutorialExit = "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1"
const tutorialReleaseFailed = "Unable to exit tutorial mode because exclusive keyboard capture could not be released. Press Fenrir+F1 or Escape to try again."
const tutorialCaptureFailed = "Warning: full keyboard capture is unavailable. Unbound keys may reach the active application."
const tutorialItem = "{command_name}, Shortcuts {command_shortcuts}, Description {command_description}"
type tutorialMode struct {
sections [3][]command.ID
section int
index int
shortcuts map[command.ID][]string
names map[command.ID]string
}
func tutorial_key_name(name string) string {
name = strings.ToLower(name)
name = strings.ReplaceAll(name, "key_kp", "keypad ")
name = strings.TrimPrefix(name, "key_")
return i18n.T(name)
}
func tutorial_shortcut(keys []string, presses int, scriptKeys []string) string {
keys = slices.Clone(keys)
var ordered []string
for _, modifier := range []string{"KEY_FENRIR", "KEY_SCRIPT", "KEY_CTRL", "KEY_SHIFT", "KEY_ALT", "KEY_META"} {
if index := slices.Index(keys, modifier); index >= 0 {
ordered = append(ordered, modifier)
keys = slices.Delete(keys, index, index+1)
}
}
keys = append(ordered, keys...)
var names []string
for _, key := range keys {
if key == "KEY_SCRIPT" {
var physical []string
for _, scriptKey := range scriptKeys {
physical = append(physical, tutorial_key_name(scriptKey))
}
names = append(names, strings.Join(physical, " / "))
} else {
names = append(names, tutorial_key_name(key))
}
}
prefix := ""
if presses > 1 {
prefix = strings.ReplaceAll(i18n.T("{} times "), "{}", strconv.Itoa(presses))
}
return prefix + strings.Join(names, ", ")
}
func new_tutorial(bindings []input.Binding, installed map[command.ID]scripts.Script, scriptKeys []string) *tutorialMode {
mode := &tutorialMode{index: -1, shortcuts: map[command.ID][]string{}, names: map[command.ID]string{}}
for _, binding := range bindings {
keys := make([]string, len(binding.Keys))
for index, key := range binding.Keys {
keys[index] = "KEY_" + key
}
for index, id := range []command.ID{binding.Command, binding.DoubleCommand, binding.TripleCommand} {
if id != "" {
mode.shortcuts[id] = append(mode.shortcuts[id], tutorial_shortcut(keys, index+1, scriptKeys))
}
}
}
for _, id := range command.IDs() {
section := 0
if len(mode.shortcuts[id]) == 0 {
section = 1
}
mode.sections[section] = append(mode.sections[section], id)
}
for id, script := range installed {
mode.names[id] = strings.ReplaceAll(strings.ToLower(script.Name), "_", " ")
mode.shortcuts[id] = []string{tutorial_shortcut(script.Keys, 1, scriptKeys)}
mode.sections[2] = append(mode.sections[2], id)
}
slices.Sort(mode.sections[2])
return mode
}
func (mode *tutorialMode) describe(id command.ID) string {
name := mode.names[id]
if name == "" {
name = strings.ReplaceAll(string(id), "_", " ")
}
shortcuts := strings.Join(mode.shortcuts[id], "; ")
if shortcuts == "" {
shortcuts = i18n.T("unbound")
}
return strings.NewReplacer("{command_name}", name, "{command_shortcuts}", shortcuts,
"{command_description}", command.Description(id)).Replace(i18n.T(tutorialItem))
}
func (mode *tutorialMode) current() string {
if mode.index < 0 || len(mode.sections[mode.section]) == 0 {
return i18n.T([]string{"Active actions", "Unbound actions", "Plugins"}[mode.section])
}
return mode.describe(mode.sections[mode.section][mode.index])
}
func (mode *tutorialMode) handle(key input.LearnKey) (string, bool) {
if key.Command == command.Help || key.Code == input.KeyEsc {
return "", true
}
if key.Bare {
switch key.Code {
case input.KeyLeft, input.KeyRight:
delta := 1
if key.Code == input.KeyLeft {
delta = -1
}
mode.section = (mode.section + delta + 3) % 3
mode.index = -1
return mode.current(), false
case input.KeyUp, input.KeyDown:
count := len(mode.sections[mode.section])
if count > 0 {
if mode.index < 0 {
mode.index = 0
if key.Code == input.KeyUp {
mode.index = count - 1
}
} else if key.Code == input.KeyUp {
mode.index = (mode.index + count - 1) % count
} else {
mode.index = (mode.index + 1) % count
}
}
return mode.current(), false
case input.KeySpace:
return mode.current(), false
}
}
var names []string
for _, code := range append(slices.Clone(key.Held), key.Code) {
names = append(names, tutorial_key_name(input.Key_name(code)))
}
text := strings.Join(names, ", ")
if key.Command != "" && key.Command != command.Unbound {
text += ". " + mode.describe(key.Command)
} else {
text += ". " + i18n.T("unbound")
}
return text, false
}
func (ui *internalUI) open_tutorial(bindings []input.Binding, installed map[command.ID]scripts.Script, scriptKeys []string) {
if ui.active {
return
}
if _, err := ui.begin(""); err != nil {
slog.Warn("tutorial capture failed", "error", err, "at", time.Now())
ui.voice.Say(i18n.T(tutorialCaptureFailed))
return
}
ui.tutorial = new_tutorial(bindings, installed, scriptKeys)
ui.voice.Say(i18n.T(tutorialEntry))
}
func (ui *internalUI) handle_tutorial(key input.LearnKey) {
text, close := ui.tutorial.handle(key)
if close {
ui.close()
return
}
ui.voice.Say(text)
}