354 lines
11 KiB
Go
354 lines
11 KiB
Go
// 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
|
|
volumePan 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
|
|
a.volumePan = ""
|
|
}
|
|
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, volumePan := 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) {
|
|
spoken := focusText
|
|
if stable && focusID == a.focusID && volumePan != "" && a.volumePan != "" {
|
|
before, after := strings.Fields(a.volumePan), strings.Fields(volumePan)
|
|
var changed []string
|
|
if before[0] != after[0] {
|
|
changed = append(changed, spoken_volume(after[0]))
|
|
}
|
|
if before[1] != after[1] {
|
|
changed = append(changed, spoken_pan(after[1]))
|
|
}
|
|
spoken = strings.Join(changed, " ")
|
|
}
|
|
frame.Focus = []presentation.Message{{Text: spoken}}
|
|
}
|
|
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.volumePan = volumePan
|
|
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, 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
|
|
volumePan := ""
|
|
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 parts := strings.Fields(value); len(parts) == 2 && strings.HasSuffix(parts[0], "dB") {
|
|
volumePan = value
|
|
value = i18n.Format("Volume and pan {0}", spoken_volume(parts[0])+" "+spoken_pan(parts[1]))
|
|
}
|
|
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), volumePan
|
|
}
|
|
}
|
|
return "", "", ""
|
|
}
|
|
|
|
// Keep signs and pan directions audible regardless of the punctuation profile.
|
|
func spoken_volume(value string) string {
|
|
if strings.HasPrefix(value, "-") {
|
|
return i18n.Format("minus {0}", value[1:])
|
|
}
|
|
if strings.HasPrefix(value, "+") {
|
|
return i18n.Format("plus {0}", value[1:])
|
|
}
|
|
return value
|
|
}
|
|
|
|
func spoken_pan(value string) string {
|
|
if value == "center" {
|
|
return i18n.T("center")
|
|
}
|
|
if amount, ok := strings.CutSuffix(value, "%L"); ok {
|
|
return i18n.Format("{0} percent left", amount)
|
|
}
|
|
if amount, ok := strings.CutSuffix(value, "%R"); ok {
|
|
return i18n.Format("{0} percent right", amount)
|
|
}
|
|
return value
|
|
}
|
|
|
|
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
|
|
}
|