Initial support for application specific overrides.
This commit is contained in:
1 parent
901fd58ba8
commit
b45f800489
37 files changed
+3909
-475
No files matched your search
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package networkmanager
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/applicationoverrides"
|
||||
"git.stormux.org/storm/fenrir/internal/command"
|
||||
"git.stormux.org/storm/fenrir/internal/config"
|
||||
"git.stormux.org/storm/fenrir/internal/presentation"
|
||||
"git.stormux.org/storm/fenrir/internal/screen"
|
||||
)
|
||||
|
||||
type Adapter struct{}
|
||||
|
||||
func (*Adapter) Name() string { return "NetworkManager" }
|
||||
func (*Adapter) Configure(settings *config.Settings) { settings.Reading.Mode = "highlight" }
|
||||
func (*Adapter) Reset() {}
|
||||
func (*Adapter) Close() {}
|
||||
func (*Adapter) Command(command.ID) (presentation.CommandResult, bool) {
|
||||
return presentation.CommandResult{}, false
|
||||
}
|
||||
|
||||
func (*Adapter) Observe(snapshot screen.Snapshot) applicationoverrides.Frame {
|
||||
// nmtui's newt dialogs retain a border and their standard title/action text.
|
||||
text := string(snapshot.Cells)
|
||||
border := strings.ContainsAny(text, "┌┐└┘╔╗╚╝") || strings.Contains(text, "+---")
|
||||
title := false
|
||||
for _, label := range []string{"NetworkManager", "Edit Connection", "Activate a connection", "Set Hostname", "Select a connection", "Edit a connection"} {
|
||||
title = title || strings.Contains(text, label)
|
||||
}
|
||||
return applicationoverrides.Frame{Recognized: border && title}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package ninjam interprets the cninjam curses client's controls and chat.
|
||||
package ninjam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/applicationoverrides"
|
||||
"git.stormux.org/storm/fenrir/internal/command"
|
||||
"git.stormux.org/storm/fenrir/internal/config"
|
||||
"git.stormux.org/storm/fenrir/internal/i18n"
|
||||
"git.stormux.org/storm/fenrir/internal/presentation"
|
||||
"git.stormux.org/storm/fenrir/internal/screen"
|
||||
)
|
||||
|
||||
type Adapter struct {
|
||||
previous screen.Snapshot
|
||||
focusID, focusText, instruction, status string
|
||||
chat []string
|
||||
latest string
|
||||
regions []applicationoverrides.Region
|
||||
historyNavigation bool
|
||||
historyAt time.Time
|
||||
}
|
||||
|
||||
func (*Adapter) Name() string { return "Ninjam" }
|
||||
func (*Adapter) Configure(settings *config.Settings) {
|
||||
settings.Reading.Mode = "highlight"
|
||||
settings.Review.LeaveReviewOnCursorChange = true
|
||||
}
|
||||
func (a *Adapter) Reset() {
|
||||
a.previous = screen.Snapshot{}
|
||||
a.focusID, a.focusText, a.instruction, a.status = "", "", "", ""
|
||||
a.chat, a.regions, a.historyNavigation = nil, nil, false
|
||||
}
|
||||
func (a *Adapter) Close() { a.Reset(); a.latest = "" }
|
||||
|
||||
// PageUp/PageDown browse existing chat. Rebaseline the next redraw instead of
|
||||
// announcing it as incoming traffic. Linux input key codes are protocol data.
|
||||
func (a *Adapter) Note_key(key uint16) {
|
||||
if key == 104 || key == 109 {
|
||||
a.historyNavigation = true
|
||||
a.historyAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) Command(id command.ID) (presentation.CommandResult, bool) {
|
||||
slot := -1
|
||||
switch id {
|
||||
case command.ReadLiveRegion1:
|
||||
slot = 0
|
||||
case command.ReadLiveRegion2:
|
||||
slot = 1
|
||||
case command.ReadLiveRegion3:
|
||||
slot = 2
|
||||
case command.ListReadingRegions:
|
||||
return presentation.CommandResult{Text: i18n.T("Ninjam regions: 1 latest chat message; 2 selected channel or control; 3 connection, tempo and interval")}, true
|
||||
}
|
||||
if slot < 0 {
|
||||
return presentation.CommandResult{}, false
|
||||
}
|
||||
text := ""
|
||||
if slot < len(a.regions) {
|
||||
text = a.regions[slot].Text
|
||||
}
|
||||
if slot == 0 {
|
||||
text = a.latest
|
||||
}
|
||||
if text == "" {
|
||||
if slot == 0 {
|
||||
text = i18n.T("No chat message received")
|
||||
} else {
|
||||
text = i18n.T("Ninjam information unavailable")
|
||||
}
|
||||
}
|
||||
return presentation.CommandResult{Text: text}, true
|
||||
}
|
||||
|
||||
func (a *Adapter) Observe(next screen.Snapshot) applicationoverrides.Frame {
|
||||
frame := applicationoverrides.Frame{}
|
||||
if !next.Active || next.Rows < 8 || next.Cols < 20 || len(next.Cells) != next.Rows*next.Cols {
|
||||
a.Reset()
|
||||
return frame
|
||||
}
|
||||
// Both fixed landmarks are drawn by showmainview, even when the chat topic
|
||||
// replaces CHAT. A chat message mentioning NINJAM is insufficient evidence.
|
||||
if strings.TrimSpace(next.Text(0)) != "LOCAL" || !strings.HasPrefix(strings.TrimSpace(next.Text(next.Rows-1)), "[QUIT NINJAM]") {
|
||||
a.Reset()
|
||||
return frame
|
||||
}
|
||||
frame.Recognized, frame.OwnsPresentation = true, true
|
||||
stable := a.previous.Active && a.previous.Terminal == next.Terminal && a.previous.Generation == next.Generation && a.previous.Rows == next.Rows && a.previous.Cols == next.Cols
|
||||
chatHeight := max(next.Rows/4, 4)
|
||||
chatTop := next.Rows - 2 - chatHeight
|
||||
focusID, focusText := selected_control(next, chatTop)
|
||||
if next.Y == next.Rows-3 {
|
||||
focusID, focusText = "chat", i18n.T("Chat message")
|
||||
frame.Input = &screen.Region{Left: 0, Top: next.Y, Right: next.Cols - 1, Bottom: next.Y}
|
||||
}
|
||||
instruction := strings.TrimSpace(next.Text(next.Rows - 2))
|
||||
if strings.Trim(instruction, "# ") == "" {
|
||||
instruction = ""
|
||||
}
|
||||
if strings.HasPrefix(instruction, "RENAME CHANNEL:") {
|
||||
focusID, focusText = "rename", i18n.T("Rename channel")
|
||||
frame.Input = &screen.Region{Left: len("RENAME CHANNEL: "), Top: next.Rows - 2, Right: next.Cols - 1, Bottom: next.Rows - 2}
|
||||
instruction = "RENAME CHANNEL:"
|
||||
}
|
||||
frame.FocusChanged = stable && focusID != "" && a.focusID != "" && focusID != a.focusID
|
||||
if focusText != "" && (!stable || focusText != a.focusText || focusID != a.focusID) {
|
||||
frame.Focus = []presentation.Message{{Text: focusText}}
|
||||
}
|
||||
if instruction != "" && (!stable || instruction != a.instruction) {
|
||||
frame.Updates = append(frame.Updates, presentation.Message{Text: instruction})
|
||||
}
|
||||
|
||||
chat := chat_lines(next, chatTop+1, next.Rows-4)
|
||||
if stable && len(chat) == 0 && len(a.chat) != 0 {
|
||||
// A transient cleared chat area during redraw is not new history.
|
||||
chat = slices.Clone(a.chat)
|
||||
}
|
||||
historyNavigation := a.historyNavigation && time.Since(a.historyAt) < 2*time.Second
|
||||
if stable && !historyNavigation {
|
||||
for _, text := range appended_lines(a.chat, chat) {
|
||||
frame.Updates = append(frame.Updates, presentation.Message{Text: text})
|
||||
a.latest = text
|
||||
}
|
||||
}
|
||||
status := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(next.Text(next.Rows-1)), "[QUIT NINJAM]"))
|
||||
if stable && a.status != "" && status != a.status {
|
||||
frame.Updates = append(frame.Updates, presentation.Message{Text: status})
|
||||
}
|
||||
interval := strings.TrimSpace(next.Text(next.Rows - 2))
|
||||
if strings.Trim(interval, "# ") == "" && interval != "" {
|
||||
interval = i18n.Format("Interval position {0} of {1}", strconv.Itoa(strings.Count(interval, "#")), strconv.Itoa(next.Cols))
|
||||
} else {
|
||||
interval = ""
|
||||
}
|
||||
a.regions = []applicationoverrides.Region{{Name: i18n.T("Latest chat message"), Text: a.latest}, {Name: i18n.T("Selected channel or control"), Text: focusText}, {Name: i18n.T("Connection, tempo and interval"), Text: strings.TrimSpace(status + " " + interval)}}
|
||||
frame.Regions = slices.Clone(a.regions)
|
||||
chatChanged := !slices.Equal(a.chat, chat)
|
||||
a.previous, a.chat = next, chat
|
||||
a.focusID, a.focusText, a.instruction, a.status = focusID, focusText, instruction, status
|
||||
a.historyNavigation = historyNavigation && !chatChanged
|
||||
return frame
|
||||
}
|
||||
|
||||
type field struct {
|
||||
start, end int
|
||||
value, label string
|
||||
}
|
||||
|
||||
func fields(line []rune) []field {
|
||||
var result []field
|
||||
for start := 0; start < len(line); start++ {
|
||||
if line[start] != '[' {
|
||||
continue
|
||||
}
|
||||
end := start + 1
|
||||
for end < len(line) && line[end] != ']' {
|
||||
end++
|
||||
}
|
||||
if end == len(line) {
|
||||
break
|
||||
}
|
||||
labelEnd := end + 1
|
||||
for labelEnd < len(line) && line[labelEnd] >= 'a' && line[labelEnd] <= 'z' {
|
||||
labelEnd++
|
||||
}
|
||||
result = append(result, field{start + 1, end, strings.TrimSpace(string(line[start+1 : end])), string(line[end+1 : labelEnd])})
|
||||
start = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func selected_control(snapshot screen.Snapshot, chatTop int) (string, string) {
|
||||
if len(snapshot.Attributes) != len(snapshot.Cells) {
|
||||
return "", ""
|
||||
}
|
||||
remote, user := false, ""
|
||||
for row := 1; row < snapshot.Rows; row++ {
|
||||
if row >= chatTop && row != snapshot.Rows-1 {
|
||||
continue
|
||||
}
|
||||
line := snapshot.Line(row)
|
||||
text := strings.TrimSpace(string(line))
|
||||
if text == "REMOTE" {
|
||||
remote = true
|
||||
continue
|
||||
}
|
||||
controls := fields(line)
|
||||
if remote && len(controls) == 0 && text != "" {
|
||||
user = text
|
||||
continue
|
||||
}
|
||||
for index, control := range controls {
|
||||
if control.start == control.end {
|
||||
continue
|
||||
}
|
||||
attribute := snapshot.Attributes[row*snapshot.Cols+control.start]
|
||||
// cninjam uses black-on-white (color pair 5), or A_STANDOUT
|
||||
// in monochrome. Ordinary bracket values are merely bold.
|
||||
selected := attribute.Style&screen.Reverse != 0 || attribute.Foreground == screen.ColorIndexed && attribute.Background == screen.ColorIndexed|7
|
||||
if !selected {
|
||||
continue
|
||||
}
|
||||
complete := true
|
||||
for col := control.start; col < control.end; col++ {
|
||||
complete = complete && snapshot.Attributes[row*snapshot.Cols+col] == attribute
|
||||
}
|
||||
if !complete {
|
||||
continue
|
||||
}
|
||||
context := ""
|
||||
if row == 1 && strings.Contains(text, "master:") {
|
||||
context = i18n.T("Master")
|
||||
if index >= 2 {
|
||||
context = i18n.T("Metronome")
|
||||
}
|
||||
} else if remote && strings.HasPrefix(text, "\"") {
|
||||
end := strings.Index(text[1:], "\"")
|
||||
if end >= 0 {
|
||||
context = i18n.Format("Remote {0}, channel {1}", user, text[1:end+1])
|
||||
}
|
||||
} else if len(controls) >= 5 {
|
||||
context = i18n.Format("Local channel {0}", controls[0].value)
|
||||
}
|
||||
value := control.value
|
||||
switch control.label {
|
||||
case "mute":
|
||||
value = i18n.T("unmuted")
|
||||
if control.value == "X" {
|
||||
value = i18n.T("muted")
|
||||
}
|
||||
case "xmit":
|
||||
value = i18n.T("not transmitting")
|
||||
if control.value == "X" {
|
||||
value = i18n.T("transmitting")
|
||||
}
|
||||
case "recv":
|
||||
value = i18n.T("not receiving")
|
||||
if control.value == "X" {
|
||||
value = i18n.T("receiving")
|
||||
}
|
||||
default:
|
||||
if strings.Contains(value, "dB") {
|
||||
value = i18n.Format("Volume and pan {0}", value)
|
||||
}
|
||||
if len(controls) >= 5 && index == 0 {
|
||||
value = i18n.Format("Channel name {0}", value)
|
||||
}
|
||||
if len(controls) >= 5 && index == 2 {
|
||||
value = i18n.Format("Input {0}", value)
|
||||
}
|
||||
if value == "del" {
|
||||
value = i18n.T("Delete channel")
|
||||
}
|
||||
if value == "new channel" {
|
||||
value = i18n.T("New channel")
|
||||
}
|
||||
if value == "no remote users" {
|
||||
value = i18n.T("No remote users")
|
||||
}
|
||||
if value == "QUIT NINJAM" {
|
||||
value = i18n.T("Quit Ninjam")
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d:%d:%s", row, index, context), strings.TrimSpace(context + " " + value)
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func chat_lines(snapshot screen.Snapshot, first, last int) []string {
|
||||
var result []string
|
||||
previousFull := false
|
||||
for row := first; row <= last; row++ {
|
||||
text := strings.TrimRight(string(snapshot.Line(row)[:snapshot.Cols-1]), " \x00")
|
||||
if text == "" {
|
||||
previousFull = false
|
||||
continue
|
||||
}
|
||||
startsMessage := strings.HasPrefix(text, "<") || strings.HasPrefix(text, "* ") || strings.HasPrefix(text, "*** ")
|
||||
if previousFull && !startsMessage && len(result) > 0 {
|
||||
result[len(result)-1] += text
|
||||
} else {
|
||||
result = append(result, text)
|
||||
}
|
||||
previousFull = len([]rune(text)) == snapshot.Cols-1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func appended_lines(previous, next []string) []string {
|
||||
if slices.Equal(previous, next) {
|
||||
return nil
|
||||
}
|
||||
for overlap := min(len(previous), len(next)); overlap > 0; overlap-- {
|
||||
if slices.Equal(previous[len(previous)-overlap:], next[:overlap]) {
|
||||
return next[overlap:]
|
||||
}
|
||||
}
|
||||
// A burst can replace every visible history row between captures. Paging
|
||||
// and layout changes are baselined by Observe before reaching this path.
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ninjam_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/applicationoverrides/ninjam"
|
||||
"git.stormux.org/storm/fenrir/internal/command"
|
||||
"git.stormux.org/storm/fenrir/internal/presentation"
|
||||
"git.stormux.org/storm/fenrir/internal/reader"
|
||||
"git.stormux.org/storm/fenrir/internal/screen"
|
||||
)
|
||||
|
||||
func snapshot(selected int, mute, volume string, messages ...string) screen.Snapshot {
|
||||
s := screen.Snapshot{Terminal: 1, Active: true, Rows: 24, Cols: 100, Y: 23, Cells: make([]rune, 2400), Attributes: make([]screen.Attribute, 2400)}
|
||||
for i := range s.Cells {
|
||||
s.Cells[i] = ' '
|
||||
s.Attributes[i] = screen.Attribute{Foreground: screen.ColorIndexed | 7, Background: screen.ColorIndexed}
|
||||
}
|
||||
paint := func(row int, text string) { copy(s.Line(row), []rune(text)) }
|
||||
paint(0, "LOCAL")
|
||||
paint(1, " master: ["+mute+"]mute ["+volume+"] | metronome: [ ]mute [0.0dB center]")
|
||||
paint(2, " [Channel] [X]xmit [Input] [ ]mute [0.0dB center] [del] <-20.0dB>")
|
||||
paint(3, "REMOTE")
|
||||
paint(4, "Username")
|
||||
paint(5, " \"Remote channel\" [X]recv [ ]mute [0.0dB center] <-20.0dB>")
|
||||
paint(16, "CHAT")
|
||||
for i, text := range messages {
|
||||
if i < 4 {
|
||||
paint(21-len(messages)+i, text)
|
||||
}
|
||||
}
|
||||
paint(22, "####")
|
||||
paint(23, "[QUIT NINJAM] : fixture.invalid : 120.0BPM 16BPI : 48000Hz")
|
||||
field := 0
|
||||
for row := range s.Rows {
|
||||
inside := false
|
||||
for col, char := range s.Line(row) {
|
||||
if char == ']' {
|
||||
inside = false
|
||||
}
|
||||
if inside && field-1 == selected {
|
||||
s.Attributes[row*s.Cols+col] = screen.Attribute{Foreground: screen.ColorIndexed, Background: screen.ColorIndexed | 7}
|
||||
}
|
||||
if char == '[' {
|
||||
inside = true
|
||||
field++
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func texts(messages []presentation.Message) []string {
|
||||
var result []string
|
||||
for _, message := range messages {
|
||||
if message.Text != "" {
|
||||
result = append(result, message.Text)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestControlsHaveContextAndState(t *testing.T) {
|
||||
for _, trial := range []struct {
|
||||
selected int
|
||||
mute, want string
|
||||
}{
|
||||
{0, " ", "Master unmuted"}, {0, "X", "Master muted"},
|
||||
{1, " ", "Master Volume and pan 0.0dB center"},
|
||||
{2, " ", "Metronome unmuted"}, {5, " ", "Local channel Channel transmitting"},
|
||||
{10, " ", "Remote Username, channel Remote channel receiving"},
|
||||
} {
|
||||
a := &ninjam.Adapter{}
|
||||
frame := a.Observe(snapshot(trial.selected, trial.mute, "0.0dB center"))
|
||||
if got := texts(frame.Focus); !frame.Recognized || !slices.Equal(got, []string{trial.want}) {
|
||||
t.Fatalf("%+v: %v", trial, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusChatMetersAndValueReflow(t *testing.T) {
|
||||
a := &ninjam.Adapter{}
|
||||
r := reader.New()
|
||||
update := func(s screen.Snapshot) []string { return texts(r.Update_application(s, a.Observe(s))) }
|
||||
update(snapshot(0, " ", "0.0dB center", "<Username> first"))
|
||||
got := update(snapshot(1, " ", "0.0dB center", "<Username> first", "<Username> second"))
|
||||
if !slices.Equal(got, []string{"Master Volume and pan 0.0dB center", "<Username> second"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
got = update(snapshot(1, " ", "-0.5dB center", "<Username> first", "<Username> second"))
|
||||
if !slices.Equal(got, []string{"Master Volume and pan -0.5dB center"}) {
|
||||
t.Fatal("reflow", got)
|
||||
}
|
||||
s := snapshot(1, " ", "-0.5dB center", "<Username> first", "<Username> second")
|
||||
copy(s.Line(22), []rune("##################"))
|
||||
copy(s.Line(2)[65:], []rune("<-10.0dB>"))
|
||||
if got := update(s); len(got) != 0 {
|
||||
t.Fatal("meter chatter", got)
|
||||
}
|
||||
if result, _ := a.Command(command.ReadLiveRegion1); result.Text != "<Username> second" {
|
||||
t.Fatal(result)
|
||||
}
|
||||
if result, _ := a.Command(command.ReadLiveRegion3); !strings.Contains(result.Text, "120.0BPM") || !strings.Contains(result.Text, "Interval position 18 of 100") {
|
||||
t.Fatal(result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewResumesOnlyOnApplicationNavigation(t *testing.T) {
|
||||
for _, direct := range []bool{false, true} {
|
||||
a, r := &ninjam.Adapter{}, reader.New()
|
||||
s := snapshot(0, " ", "0.0dB center", "<Username> old")
|
||||
r.Update_application(s, a.Observe(s))
|
||||
r.Command(command.CurrentLine)
|
||||
if direct {
|
||||
r.Note_input(true, false, false)
|
||||
}
|
||||
s = snapshot(1, " ", "0.0dB center", "<Username> old", "<Username> new")
|
||||
got := texts(r.Update_application(s, a.Observe(s)))
|
||||
want := []string{"<Username> new"}
|
||||
if direct {
|
||||
want = append([]string{"Master Volume and pan 0.0dB center"}, want...)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatal(direct, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatScrollPagingRepetitionAndResize(t *testing.T) {
|
||||
a := &ninjam.Adapter{}
|
||||
a.Observe(snapshot(0, " ", "0.0dB center", "<Username> a", "<Username> b", "<Username> c", "<Username> d"))
|
||||
frame := a.Observe(snapshot(0, " ", "0.0dB center", "<Username> b", "<Username> c", "<Username> d", "<Username> d"))
|
||||
if got := texts(frame.Updates); !slices.Equal(got, []string{"<Username> d"}) {
|
||||
t.Fatal("repeated message", got)
|
||||
}
|
||||
a.Note_key(104)
|
||||
// An interval update can arrive before the application's history redraw.
|
||||
a.Observe(snapshot(0, " ", "0.0dB center", "<Username> b", "<Username> c", "<Username> d", "<Username> d"))
|
||||
frame = a.Observe(snapshot(0, " ", "0.0dB center", "<Username> old", "<Username> b", "<Username> c", "<Username> d"))
|
||||
if len(frame.Updates) != 0 {
|
||||
t.Fatal("history announced", frame.Updates)
|
||||
}
|
||||
s := snapshot(0, " ", "0.0dB center", "<Username> old history")
|
||||
s.Generation++
|
||||
if frame = a.Observe(s); len(frame.Updates) != 0 {
|
||||
t.Fatal("buffer rebaseline", frame.Updates)
|
||||
}
|
||||
copy(s.Line(0), []rune("SHELL"))
|
||||
if a.Observe(s).Recognized {
|
||||
t.Fatal("unrelated screen recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatAlongsideTypingAndMutedAutomaticReading(t *testing.T) {
|
||||
a, r := &ninjam.Adapter{}, reader.New()
|
||||
s := snapshot(-1, " ", "0.0dB center", "<Username> old")
|
||||
s.Y = 21
|
||||
r.Update_application(s, a.Observe(s))
|
||||
r.Note_input(true, true, false)
|
||||
s = snapshot(-1, " ", "0.0dB center", "<Username> old", "<Username> incoming")
|
||||
s.Y, s.X = 21, 1
|
||||
s.Line(21)[0] = 'a'
|
||||
got := texts(r.Update_application(s, a.Observe(s)))
|
||||
if !slices.Equal(got, []string{"a", "<Username> incoming"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
r.Configure(false, true)
|
||||
s = snapshot(-1, " ", "0.0dB center", "<Username> incoming", "<Username> silent")
|
||||
s.Y = 21
|
||||
if got := texts(r.Update_application(s, a.Observe(s))); len(got) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if result, _ := a.Command(command.ReadLiveRegion1); result.Text != "<Username> silent" {
|
||||
t.Fatal("latest lost while autoread off", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBurstAndTransientClearedArea(t *testing.T) {
|
||||
a := &ninjam.Adapter{}
|
||||
old := []string{"<Username> a", "<Username> b", "<Username> c", "<Username> d"}
|
||||
a.Observe(snapshot(0, " ", "0.0dB center", old...))
|
||||
if frame := a.Observe(snapshot(0, " ", "0.0dB center")); len(frame.Updates) != 0 {
|
||||
t.Fatal("cleared area announced", frame.Updates)
|
||||
}
|
||||
if frame := a.Observe(snapshot(0, " ", "0.0dB center", old...)); len(frame.Updates) != 0 {
|
||||
t.Fatal("redraw replayed history", frame.Updates)
|
||||
}
|
||||
burst := []string{"<Username> e", "<Username> f", "<Username> g", "<Username> h"}
|
||||
if got := texts(a.Observe(snapshot(0, " ", "0.0dB center", burst...)).Updates); !slices.Equal(got, burst) {
|
||||
t.Fatal("visible burst lost", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedChatRecallAndChangedGeometry(t *testing.T) {
|
||||
a := &ninjam.Adapter{}
|
||||
a.Observe(snapshot(0, " ", "0.0dB center", "<Username> old"))
|
||||
first := "<Username> " + strings.Repeat("x", 99-len("<Username> "))
|
||||
frame := a.Observe(snapshot(0, " ", "0.0dB center", "<Username> old", first, " continuation"))
|
||||
want := first + " continuation"
|
||||
if got := texts(frame.Updates); !slices.Equal(got, []string{want}) {
|
||||
t.Fatal("wrapped message", got)
|
||||
}
|
||||
if result, _ := a.Command(command.ReadLiveRegion1); result.Text != want {
|
||||
t.Fatal("recall lost continuation", result)
|
||||
}
|
||||
old := snapshot(0, " ", "0.0dB center")
|
||||
resized := screen.Snapshot{Terminal: 1, Active: true, Rows: 20, Cols: 80, Y: 19, Cells: make([]rune, 1600), Attributes: make([]screen.Attribute, 1600)}
|
||||
for i := range resized.Cells {
|
||||
resized.Cells[i] = ' '
|
||||
}
|
||||
for row := 0; row < 3; row++ {
|
||||
copy(resized.Line(row), old.Line(row)[:80])
|
||||
copy(resized.Attributes[row*80:(row+1)*80], old.Attributes[row*100:row*100+80])
|
||||
}
|
||||
copy(resized.Line(13), []rune("CHAT"))
|
||||
copy(resized.Line(16), []rune("<Username> old history"))
|
||||
copy(resized.Line(18), []rune("#####"))
|
||||
copy(resized.Line(19), old.Line(23)[:80])
|
||||
if frame := a.Observe(resized); len(frame.Updates) != 0 {
|
||||
t.Fatal("resize replayed history", frame.Updates)
|
||||
}
|
||||
copy(resized.Line(15), resized.Line(16))
|
||||
clear(resized.Line(16))
|
||||
copy(resized.Line(16), []rune("<Username> after resize"))
|
||||
if got := texts(a.Observe(resized).Updates); !slices.Equal(got, []string{"<Username> after resize"}) {
|
||||
t.Fatal("resized chat region", got)
|
||||
}
|
||||
if result, _ := a.Command(command.ReadLiveRegion3); !strings.Contains(result.Text, "of 80") {
|
||||
t.Fatal("resized interval region", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package applicationoverrides defines built-in, event-loop-owned application
|
||||
// adapters. Adapters interpret snapshots; they never own devices or persistence.
|
||||
package applicationoverrides
|
||||
|
||||
import (
|
||||
"git.stormux.org/storm/fenrir/internal/command"
|
||||
"git.stormux.org/storm/fenrir/internal/config"
|
||||
"git.stormux.org/storm/fenrir/internal/presentation"
|
||||
"git.stormux.org/storm/fenrir/internal/screen"
|
||||
)
|
||||
|
||||
type Region struct {
|
||||
Name, Text string
|
||||
}
|
||||
|
||||
// Frame supplements the ordinary reader. OwnsPresentation replaces generic
|
||||
// automatic diffs, never raw review/copy or typing echo. Updates are independent
|
||||
// of focus: a focus change must not swallow a simultaneous chat message.
|
||||
type Frame struct {
|
||||
Recognized, OwnsPresentation, FocusChanged bool
|
||||
Focus, Updates []presentation.Message
|
||||
Regions []Region
|
||||
// Input limits typing/deletion echo to an application's actual input field.
|
||||
Input *screen.Region
|
||||
}
|
||||
|
||||
type Adapter interface {
|
||||
Name() string
|
||||
Configure(*config.Settings)
|
||||
Observe(screen.Snapshot) Frame
|
||||
Command(command.ID) (presentation.CommandResult, bool)
|
||||
// Reset forgets screen-derived baselines after suspension or layout changes.
|
||||
Reset()
|
||||
Close()
|
||||
}
|
||||
Reference in new issue
Block a user