Add Mumble message review shortcuts
This commit is contained in:
1 parent
d5f8a392cf
commit
64440fbb78
18 files changed
+484
-30
No files matched your search
@@ -26,6 +26,17 @@ type messageCopyResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func messageHistorySlots(process string) int {
|
||||
switch process {
|
||||
case "pidgin":
|
||||
return 9
|
||||
case "mumble":
|
||||
return 12
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// messagingController belongs to the accessibility event loop. Histories survive
|
||||
// focus changes, retire with their native window/transcript, and clear on lock.
|
||||
// Its one clipboard worker has a bounded queue and never performs native queries.
|
||||
@@ -87,8 +98,8 @@ func (controller *messagingController) handle(event atspi.Event) bool {
|
||||
controller.history.Retire(id.BusName, id.Path)
|
||||
}
|
||||
message := event.ChatMessage
|
||||
if message == nil || event.Type != atspi.TextInsertedEvent || event.Source.ApplicationProcess != "pidgin" ||
|
||||
event.Source.Defunct || event.Source.Role != "text" ||
|
||||
if message == nil || event.Type != atspi.TextInsertedEvent || messageHistorySlots(event.Source.ApplicationProcess) == 0 ||
|
||||
event.Source.Defunct || (event.Source.Role != "text" && !(event.Source.ApplicationProcess == "mumble" && event.Source.Role == "label")) ||
|
||||
message.Provider != event.Source.BusName || message.Conversation != event.Source.Path ||
|
||||
!event.Source.ID().Valid() || event.Window != atspi.NewObjectID(message.Provider, message.Window) {
|
||||
return false
|
||||
@@ -117,7 +128,7 @@ func (controller *messagingController) present(text string, background bool) {
|
||||
|
||||
func (controller *messagingController) review(command input.Command, current atspi.Snapshot) {
|
||||
index, copyMessage, ok := input.MessageCommandIndex(command)
|
||||
if !ok || controller.gate.Locked() || controller.gate.Suspended() || current.ApplicationProcess != "pidgin" {
|
||||
if !ok || controller.gate.Locked() || controller.gate.Suspended() || index >= messageHistorySlots(current.ApplicationProcess) {
|
||||
return
|
||||
}
|
||||
message, exists := controller.history.Previous(current.BusName, index)
|
||||
|
||||
@@ -7,6 +7,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -26,7 +27,71 @@ func pidginMessageEvent(text string) atspi.Event {
|
||||
return event
|
||||
}
|
||||
|
||||
func TestMumbleMessagingReviewBoundsOwnershipAndRetirement(t *testing.T) {
|
||||
queue, braille := &learnSpeechRecorder{}, &flashRecorder{}
|
||||
controller := newMessagingController(context.Background(), queue, nil, nil, func(_ context.Context, text string) error {
|
||||
if text != "Mumble message 3" {
|
||||
return fmt.Errorf("copied wrong message: %q", text)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
controller.braille = braille
|
||||
defer controller.stop()
|
||||
current := atspi.Snapshot{ApplicationProcess: "mumble", BusName: ":1.42"}
|
||||
for i := 0; i < 15; i++ {
|
||||
event := pidginMessageEvent(fmt.Sprintf("Mumble message %d", i))
|
||||
event.Source.ApplicationProcess = "mumble"
|
||||
if i%2 == 0 {
|
||||
event.Source.Role = "label"
|
||||
}
|
||||
if !controller.handle(event) {
|
||||
t.Fatal("Mumble log insertion was not handled")
|
||||
}
|
||||
}
|
||||
for i := 0; i < 12; i++ {
|
||||
controller.review(input.MessageCommand(i, false), current)
|
||||
want := fmt.Sprintf("Mumble message %d", 14-i)
|
||||
if queue.texts[len(queue.texts)-1] != want || braille.messages[len(braille.messages)-1] != want {
|
||||
t.Fatalf("review slot %d did not present %q", i, want)
|
||||
}
|
||||
}
|
||||
controller.review(input.MessageCommand(11, true), current)
|
||||
select {
|
||||
case result := <-controller.results:
|
||||
if result.err != nil {
|
||||
t.Fatal(result.err)
|
||||
}
|
||||
controller.copied(result)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Mumble F12 copy did not complete")
|
||||
}
|
||||
if queue.texts[len(queue.texts)-1] != "Copied Mumble message 3 to clipboard." {
|
||||
t.Fatal("Mumble F12 copy was not confirmed")
|
||||
}
|
||||
count := len(queue.texts)
|
||||
for _, focus := range []atspi.Snapshot{
|
||||
{ApplicationProcess: "mumble", BusName: ":1.43"},
|
||||
{ApplicationProcess: "chromium", BusName: ":1.42"},
|
||||
{ApplicationProcess: "pidgin", BusName: ":1.42"},
|
||||
} {
|
||||
controller.review(input.MessageCommand(11, false), focus)
|
||||
}
|
||||
retired := atspi.NewEvent(atspi.DefunctStateEvent, atspi.Snapshot{BusName: ":1.42", Path: "/transcript", Defunct: true})
|
||||
retired.Detail1 = 1
|
||||
controller.handle(retired)
|
||||
controller.review(input.MessageCommand(0, false), current)
|
||||
if len(queue.texts) != count || len(braille.messages) != count {
|
||||
t.Fatal("unowned or retired history was presented")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T) {
|
||||
for _, process := range []string{"pidgin", "mumble"} {
|
||||
t.Run(process, func(t *testing.T) { testMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t, process) })
|
||||
}
|
||||
}
|
||||
|
||||
func testMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T, process string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event)}
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
|
||||
@@ -37,13 +102,18 @@ func TestMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T)
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
focus := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/window", "/entry")
|
||||
focus.Source.ApplicationProcess, focus.Source.Role, focus.Source.Name = "pidgin", "entry", "Message input"
|
||||
focus.Source.ApplicationProcess, focus.Source.Role, focus.Source.Name = process, "entry", "Message input"
|
||||
focus.Source.Editable, focus.Source.Focused, focus.Detail1 = true, true, 1
|
||||
adapter.events <- focus
|
||||
expectActivationSpeech(t, queue.spoken, "Message input, entry")
|
||||
adapter.events <- pidginMessageEvent("First message")
|
||||
messageEvent := func(text string) atspi.Event {
|
||||
event := pidginMessageEvent(text)
|
||||
event.Source.ApplicationProcess = process
|
||||
return event
|
||||
}
|
||||
adapter.events <- messageEvent("First message")
|
||||
expectActivationSpeech(t, queue.spoken, "First message")
|
||||
adapter.events <- pidginMessageEvent("Second message")
|
||||
adapter.events <- messageEvent("Second message")
|
||||
expectActivationSpeech(t, queue.spoken, "Second message")
|
||||
requests <- input.MessageCommand(0, false)
|
||||
expectActivationSpeech(t, queue.spoken, "Second message")
|
||||
|
||||
@@ -495,12 +495,12 @@ func dispatch_native_event(event *C.AtspiEvent, userData unsafe.Pointer, deferre
|
||||
return
|
||||
}
|
||||
if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 &&
|
||||
adapter.applicationProcesses[originID.BusName] == "pidgin" {
|
||||
(adapter.applicationProcesses[originID.BusName] == "pidgin" || adapter.applicationProcesses[originID.BusName] == "mumble") {
|
||||
// Read-only transcripts need not own native focus. Forward their
|
||||
// retirement before the ordinary dead-object early return, without
|
||||
// querying a destroyed accessible or broadening focus ownership.
|
||||
retired := NewEvent(eventType, Snapshot{BusName: originID.BusName, Path: originID.Path,
|
||||
ApplicationProcess: "pidgin", Defunct: true})
|
||||
ApplicationProcess: adapter.applicationProcesses[originID.BusName], Defunct: true})
|
||||
retired.Detail1 = 1
|
||||
adapter.enqueueSecureEvent(retired, secureEpoch)
|
||||
return
|
||||
@@ -518,11 +518,17 @@ func dispatch_native_event(event *C.AtspiEvent, userData unsafe.Pointer, deferre
|
||||
// notification script needs their semantics before that early return,
|
||||
// including when the provider is outside foreground ownership.
|
||||
webNotification := adapter.webNotification(event)
|
||||
if message := adapter.pidginMessage(event); message != nil {
|
||||
message := adapter.pidginMessage(event)
|
||||
if message == nil {
|
||||
message = adapter.mumbleMessage(event)
|
||||
}
|
||||
if message != nil {
|
||||
// A transcript insertion is a passive announcement. Keep it out of
|
||||
// native focus capture, selected-descendant resolution and review.
|
||||
roleName := C.atspi_accessible_get_role_name(event.source, nil)
|
||||
chatEvent := NewEvent(TextInsertedEvent, Snapshot{BusName: message.Provider,
|
||||
Path: message.Conversation, Role: "text", ApplicationProcess: "pidgin"})
|
||||
Path: message.Conversation, Role: C.GoString(roleName), ApplicationProcess: adapter.applicationProcesses[originID.BusName]})
|
||||
C.g_free(C.gpointer(roleName))
|
||||
chatEvent.ChatMessage = message
|
||||
chatEvent.Window = NewObjectID(message.Provider, message.Window)
|
||||
chatEvent.Foreground = adapter.Foreground()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var mumbleTimestamp = regexp.MustCompile(`^\[\d{2}:\d{2}:\d{2}\]\s*`)
|
||||
|
||||
// Match legacy Mumble's inserted-message cleanup. Qt can insert the timestamp
|
||||
// separately, and uses paragraph separators and noncharacters in its log.
|
||||
func mumbleMessageText(text string) string {
|
||||
text = strings.Map(func(character rune) rune {
|
||||
if character >= '\ufdd0' && character <= '\ufdef' {
|
||||
return -1
|
||||
}
|
||||
return character
|
||||
}, text)
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
return mumbleTimestamp.ReplaceAllString(text, "")
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
/*
|
||||
#include "adapter_native.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/messaging"
|
||||
)
|
||||
|
||||
// Only Mumble's named, read-only Activity log supplies message history. The
|
||||
// bounded walk copies its owning window; no native handles survive the callback.
|
||||
func (adapter *Adapter) mumbleMessage(event *C.AtspiEvent) *messaging.Message {
|
||||
if canonicalObjectChangeEventType(C.GoString(event._type)) != TextInsertedEvent || adapter.applicationProcess(event.source) != "mumble" {
|
||||
return nil
|
||||
}
|
||||
role := C.atspi_accessible_get_role(event.source, nil)
|
||||
if role != C.ATSPI_ROLE_TEXT && role != C.ATSPI_ROLE_LABEL {
|
||||
return nil
|
||||
}
|
||||
name := C.atspi_accessible_get_name(event.source, nil)
|
||||
activityLog := C.GoString(name) == "Activity log"
|
||||
C.g_free(C.gpointer(name))
|
||||
if !activityLog {
|
||||
return nil
|
||||
}
|
||||
states := C.atspi_accessible_get_state_set(event.source)
|
||||
if states == nil {
|
||||
return nil
|
||||
}
|
||||
valid := C.atspi_state_set_contains(states, C.ATSPI_STATE_EDITABLE) == 0 &&
|
||||
C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0
|
||||
C.g_object_unref(C.gpointer(states))
|
||||
if !valid {
|
||||
return nil
|
||||
}
|
||||
id := nativeObjectID(event.source)
|
||||
message := &messaging.Message{Provider: id.BusName, Conversation: id.Path, RoomName: "Mumble"}
|
||||
current := C.atspi_accessible_get_parent(event.source, nil)
|
||||
deadline := time.Now().Add(50 * time.Millisecond)
|
||||
for depth := 0; current != nil && depth < 32 && time.Now().Before(deadline); depth++ {
|
||||
if isWindowRole(C.atspi_accessible_get_role(current, nil)) {
|
||||
window := nativeObjectID(current)
|
||||
if window.BusName == id.BusName {
|
||||
message.Window = window.Path
|
||||
}
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
current = nil
|
||||
break
|
||||
}
|
||||
parent := C.atspi_accessible_get_parent(current, nil)
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
current = parent
|
||||
}
|
||||
if current != nil {
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
}
|
||||
if !id.Valid() || message.Window == "" {
|
||||
return nil
|
||||
}
|
||||
payload := C.cthulhu_event_payload(event)
|
||||
message.Text = mumbleMessageText(sanitizeTextContext(C.GoString(payload)))
|
||||
C.g_free(C.gpointer(payload))
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNativeMumbleTranscriptCapture(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_MUMBLE") != "1" {
|
||||
t.Skip("requires private Qt/Xorg/AT-SPI session")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
adapter := New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
for _, process := range []string{"mumble", "messaging-test"} {
|
||||
t.Run(process, func(t *testing.T) {
|
||||
command := exec.CommandContext(ctx, "python3", "../../tests/qt_messaging.py", process)
|
||||
input, err := command.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command.Stderr = os.Stderr
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { input.Close(); command.Process.Kill(); command.Wait() }()
|
||||
line, err := bufio.NewReader(output).ReadString('\n')
|
||||
fields := strings.Fields(line)
|
||||
if err != nil || len(fields) != 2 || fields[0] != "ready" {
|
||||
t.Fatalf("fixture readiness: %q, %v", line, err)
|
||||
}
|
||||
if _, err := adapter.CaptureDesktop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", fields[1]).CombinedOutput(); err != nil {
|
||||
t.Fatalf("focus: %s, %v", out, err)
|
||||
}
|
||||
fmt.Fprintln(input, "messages\nbarrier")
|
||||
var messages []string
|
||||
for {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if event.Source.ApplicationProcess != process {
|
||||
continue
|
||||
}
|
||||
if message := event.ChatMessage; message != nil {
|
||||
if process != "mumble" {
|
||||
t.Fatal("unrelated process received chat metadata")
|
||||
}
|
||||
if message.Provider != event.Source.BusName || message.Conversation != event.Source.Path || message.Window == "" || message.RoomName != "Mumble" {
|
||||
t.Fatalf("incomplete message identities: %+v", message)
|
||||
}
|
||||
if message.Text != "" {
|
||||
messages = append(messages, message.Text)
|
||||
}
|
||||
}
|
||||
if event.Type == TextInsertedEvent && event.Payload == "Messaging barrier" {
|
||||
want := "Username: hello world 界|Repeated message|Repeated message"
|
||||
if process == "mumble" && strings.Join(messages, "|") != want {
|
||||
t.Fatalf("captured messages = %q", messages)
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("transcript/barrier missing: %v (messages=%q)", ctx.Err(), messages)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMumbleMessageText(t *testing.T) {
|
||||
for _, test := range []struct{ input, want string }{
|
||||
{"[12:34:56]", ""},
|
||||
{" \ufdd0[12:34:56]\ufdef \n", ""},
|
||||
{"[12:34:56] Username: hello\u2028world \ufdd0界\ufdef", "Username: hello world 界"},
|
||||
{"Username: [12:34:56] hello", "Username: [12:34:56] hello"},
|
||||
{"Username: hello\n\tworld", "Username: hello world"},
|
||||
{" \n\ufdd0\ufdef", ""},
|
||||
} {
|
||||
if got := mumbleMessageText(test.input); got != test.want {
|
||||
t.Errorf("cleanup %q = %q, want %q", test.input, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,7 @@ func DefaultCommandBindings(layout KeyboardLayout) CommandBindings {
|
||||
{Symbol: leftKeySymbol, Modifiers: controlModifier, CthulhuModifier: true}: ObjectPreviousSibling,
|
||||
{Symbol: rightKeySymbol, Modifiers: controlModifier, CthulhuModifier: true}: ObjectNextSibling,
|
||||
}
|
||||
for index := 0; index < 9; index++ {
|
||||
for index := 0; index < 12; index++ {
|
||||
bindings[CommandChord{Symbol: 0xffbe + uint32(index), CthulhuModifier: true}] = MessageCommand(index, false)
|
||||
bindings[CommandChord{Symbol: 0xffbe + uint32(index), Modifiers: 1, CthulhuModifier: true}] = MessageCommand(index, true)
|
||||
}
|
||||
|
||||
@@ -4,19 +4,22 @@
|
||||
|
||||
package input
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Message commands are independent of the physical key. Configured bindings,
|
||||
// learn mode and both keyboard layouts use the same numbered requests.
|
||||
func MessageCommand(index int, copyMessage bool) Command {
|
||||
if index < 0 || index >= 9 {
|
||||
if index < 0 || index >= 12 {
|
||||
return ""
|
||||
}
|
||||
action := "review"
|
||||
if copyMessage {
|
||||
action = "copy"
|
||||
}
|
||||
return Command(action + "-chat-message-" + string(rune('1'+index)))
|
||||
return Command(action + "-chat-message-" + strconv.Itoa(index+1))
|
||||
}
|
||||
|
||||
func MessageCommandIndex(command Command) (index int, copyMessage, ok bool) {
|
||||
@@ -25,8 +28,9 @@ func MessageCommandIndex(command Command) (index int, copyMessage, ok bool) {
|
||||
number, found = strings.CutPrefix(string(command), "copy-chat-message-")
|
||||
copyMessage = true
|
||||
}
|
||||
if !found || len(number) != 1 || number[0] < '1' || number[0] > '9' {
|
||||
value, err := strconv.Atoi(number)
|
||||
if !found || err != nil || value < 1 || value > 12 || strconv.Itoa(value) != number {
|
||||
return 0, false, false
|
||||
}
|
||||
return int(number[0] - '1'), copyMessage, true
|
||||
return value - 1, copyMessage, true
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func TestMessagingBindingsAndExplicitAssignments(t *testing.T) {
|
||||
if bindings[CommandChord{Symbol: 'h', CthulhuModifier: true}] != ToggleLearnMode {
|
||||
t.Fatal("learn mode shortcut changed")
|
||||
}
|
||||
for index := 0; index < 9; index++ {
|
||||
for index := 0; index < 12; index++ {
|
||||
for _, copyMessage := range []bool{false, true} {
|
||||
modifiers := uint16(0)
|
||||
if copyMessage {
|
||||
@@ -45,7 +45,7 @@ func TestMessagingBindingsAndExplicitAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, command := range []Command{"review-chat-message-0", "review-chat-message-10", "copy-chat-message-01", ToggleLearnMode} {
|
||||
for _, command := range []Command{"review-chat-message-0", "review-chat-message-13", "copy-chat-message-01", "review-chat-message-+1", "review-chat-message- 1", ToggleLearnMode} {
|
||||
if _, _, ok := MessageCommandIndex(command); ok {
|
||||
t.Fatalf("accepted %q", command)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ package messaging
|
||||
|
||||
import "strings"
|
||||
|
||||
const HistorySize = 9
|
||||
const HistorySize = 12
|
||||
const maxProviders = 32
|
||||
|
||||
// Message contains no native objects. Provider is the unique accessibility bus
|
||||
|
||||
@@ -12,17 +12,17 @@ import (
|
||||
|
||||
func TestHistoryOrdersConversationsAndIsolatesApplicationInstances(t *testing.T) {
|
||||
var history History
|
||||
for i := 0; i < 12; i++ {
|
||||
for i := 0; i < 15; i++ {
|
||||
history.Add(Message{Provider: ":1.42", Conversation: fmt.Sprintf("/room%d", i%2), Window: "/window", Text: fmt.Sprintf("Message %d", i)})
|
||||
}
|
||||
history.Add(Message{Provider: ":1.43", Conversation: "/room0", Window: "/window", Text: "Other instance"})
|
||||
for i := 0; i < HistorySize; i++ {
|
||||
message, ok := history.Previous(":1.42", i)
|
||||
if !ok || message.Text != fmt.Sprintf("Message %d", 11-i) {
|
||||
if !ok || message.Text != fmt.Sprintf("Message %d", 14-i) {
|
||||
t.Fatalf("slot %d = %+v, %t", i, message, ok)
|
||||
}
|
||||
}
|
||||
for _, index := range []int{-1, 9} {
|
||||
for _, index := range []int{-1, HistorySize} {
|
||||
if _, ok := history.Previous(":1.42", index); ok {
|
||||
t.Fatal("out-of-range slot exists")
|
||||
}
|
||||
|
||||
@@ -169,6 +169,12 @@ func TestNativeLearnModeCapturesKeysAndRestoresCommands(t *testing.T) {
|
||||
}
|
||||
key("Insert+Shift_L+F9")
|
||||
want(input.MessageCommand(8, true))
|
||||
for index := 9; index < 12; index++ {
|
||||
key(fmt.Sprintf("Insert+F%d", index+1))
|
||||
want(input.MessageCommand(index, false))
|
||||
key(fmt.Sprintf("Insert+Shift_L+F%d", index+1))
|
||||
want(input.MessageCommand(index, true))
|
||||
}
|
||||
for _, opener := range []string{"Insert+h"} {
|
||||
key(opener)
|
||||
want(input.ToggleLearnMode)
|
||||
|
||||
Reference in new issue
Block a user