// 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 }