806 lines
22 KiB
Go
806 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"git.stormux.org/storm/barnard/gumble/gumble"
|
|
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
|
|
"git.stormux.org/storm/barnard/uiterm"
|
|
"github.com/kennygrant/sanitize"
|
|
"github.com/nsf/termbox-go"
|
|
)
|
|
|
|
const (
|
|
uiViewLogo = "logo"
|
|
uiViewTop = "top"
|
|
uiViewStatus = "status"
|
|
uiViewInput = "input"
|
|
uiViewInputStatus = "inputstatus"
|
|
uiViewOutput = "output"
|
|
uiViewTree = "tree"
|
|
uiViewAdmin = "admin"
|
|
)
|
|
|
|
// esc makes server-supplied text safe for a terminal as well as for HTML.
|
|
// HTML escaping alone leaves ANSI, OSC, DEL, and bidi/control characters able
|
|
// to alter terminal state or obscure the displayed text.
|
|
func esc(str string) string {
|
|
clean := strings.Map(func(r rune) rune {
|
|
if r == 0x7f || unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, str)
|
|
return sanitize.HTML(clean)
|
|
}
|
|
|
|
// postUI is the only path network and audio callbacks use to touch terminal
|
|
// widgets. Work is dropped during shutdown or queue overload.
|
|
func (b *Barnard) postUI(fn func()) {
|
|
if b.Ui != nil {
|
|
b.Ui.Post(fn)
|
|
}
|
|
}
|
|
|
|
func (b *Barnard) Notify(event string, who string, what string) {
|
|
// Notifications are best-effort: a slow external command must not block a
|
|
// UI or network callback. New events are dropped once the bounded queue is full.
|
|
select {
|
|
case b.notifyChannel <- []string{event, who, what}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (b *Barnard) SetSelectedUser(user *gumble.User) {
|
|
b.setSelectedUserValue(user)
|
|
if user == nil {
|
|
if len(b.UiInput.Text) > 0 {
|
|
}
|
|
b.UpdateInputStatus(fmt.Sprintf("[%s]", b.Client.Self.Channel.Name))
|
|
} else {
|
|
b.UpdateInputStatus(fmt.Sprintf("[@%s]", user.Name))
|
|
}
|
|
}
|
|
|
|
func (b *Barnard) GetInputStatus() string {
|
|
return b.UiInputStatus.Text
|
|
}
|
|
|
|
func (b *Barnard) UpdateInputStatus(status string) {
|
|
status = truncateInputStatus(status)
|
|
if b.Ui == nil {
|
|
return
|
|
}
|
|
b.Ui.Post(func() {
|
|
b.UiInputStatus.Text = status
|
|
// The initial connection status arrives after Run's first layout. Relayout
|
|
// so the prompt has cells to draw before focus is changed.
|
|
width, height := termbox.Size()
|
|
b.OnUiResize(b.Ui, width, height)
|
|
b.RebuildUserChannelTreePreservingSelection()
|
|
b.Ui.Refresh()
|
|
})
|
|
}
|
|
|
|
// truncateInputStatus limits terminal cells without splitting UTF-8 runes.
|
|
func truncateInputStatus(status string) string {
|
|
chars := []rune(status)
|
|
if len(chars) > 20 {
|
|
return string(chars[:17]) + "..." + "]"
|
|
}
|
|
return status
|
|
}
|
|
|
|
func (b *Barnard) AddOutputLine(line string) {
|
|
now := time.Now()
|
|
formatted := fmt.Sprintf("%s [%02d:%02d:%02d]", line, now.Hour(), now.Minute(), now.Second())
|
|
if b.Ui == nil {
|
|
return
|
|
}
|
|
b.Ui.Post(func() { b.UiOutput.AddLine(formatted) })
|
|
}
|
|
|
|
func (b *Barnard) AddOutputMessage(sender *gumble.User, message string) {
|
|
if sender == nil {
|
|
b.AddOutputLine(message)
|
|
} else {
|
|
b.AddOutputLine(fmt.Sprintf("%s: %s", sender.Name, strings.TrimSpace(esc(message))))
|
|
}
|
|
}
|
|
|
|
func (b *Barnard) AddOutputPrivateMessage(source *gumble.User, dest *gumble.User, message string) {
|
|
var sender string
|
|
if source == nil {
|
|
sender = "Server"
|
|
} else {
|
|
sender = source.Name
|
|
}
|
|
b.AddOutputLine(fmt.Sprintf("pm/%s/%s: %s", sender, dest.Name, strings.TrimSpace(esc(message))))
|
|
}
|
|
|
|
func (b *Barnard) OnTimestampToggle(ui *uiterm.Ui, key uiterm.Key) {
|
|
b.UiOutput.ToggleTimestamps()
|
|
}
|
|
|
|
func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) {
|
|
enabled := b.toggleNoiseSuppression()
|
|
|
|
if enabled {
|
|
b.UpdateGeneralStatus("Noise suppression: ON", false)
|
|
} else {
|
|
b.UpdateGeneralStatus("Noise suppression: OFF", false)
|
|
}
|
|
}
|
|
|
|
func (b *Barnard) toggleNoiseSuppression() bool {
|
|
enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
|
|
if err := b.UserConfig.SetNoiseSuppressionEnabled(enabled); err != nil {
|
|
b.AddOutputLine("Noise suppression: could not save setting: " + err.Error())
|
|
}
|
|
b.NoiseSuppressor.SetEnabled(enabled)
|
|
return enabled
|
|
}
|
|
|
|
func (b *Barnard) OnAGCToggle(ui *uiterm.Ui, key uiterm.Key) {
|
|
enabled := b.toggleAGC()
|
|
|
|
if enabled {
|
|
b.UpdateGeneralStatus("AGC: ON", false)
|
|
} else {
|
|
b.UpdateGeneralStatus("AGC: OFF", false)
|
|
}
|
|
}
|
|
|
|
// toggleAGC flips the saved AGC preference and applies it to the active
|
|
// stream, returning the new state.
|
|
func (b *Barnard) toggleAGC() bool {
|
|
enabled := !b.UserConfig.GetAGCEnabled()
|
|
if err := b.UserConfig.SetAGCEnabled(enabled); err != nil {
|
|
b.AddOutputLine("AGC: could not save setting: " + err.Error())
|
|
}
|
|
b.withStream(func(stream *gumbleopenal.Stream) {
|
|
stream.SetAGCEnabled(enabled)
|
|
})
|
|
return enabled
|
|
}
|
|
|
|
func (b *Barnard) UpdateGeneralStatus(text string, notice bool) {
|
|
b.postUI(func() {
|
|
b.statusText = text
|
|
b.statusNotice = notice
|
|
b.renderGeneralStatusNow()
|
|
})
|
|
}
|
|
|
|
func (b *Barnard) renderGeneralStatus() {
|
|
b.postUI(func() { b.renderGeneralStatusNow() })
|
|
}
|
|
|
|
// renderGeneralStatusNow must run on the UI-owning goroutine.
|
|
func (b *Barnard) renderGeneralStatusNow() {
|
|
text := b.statusText
|
|
notice := b.statusNotice
|
|
if notice {
|
|
b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
|
|
b.UiStatus.Bg = uiterm.ColorRed
|
|
} else {
|
|
b.UiStatus.Fg = uiterm.ColorBlack
|
|
b.UiStatus.Bg = uiterm.ColorWhite
|
|
}
|
|
if b.isRecordingActive() {
|
|
switch strings.TrimSpace(text) {
|
|
case "Idle":
|
|
text = " Idle Rec "
|
|
case "Tx":
|
|
text = " Tx Rec "
|
|
case "File":
|
|
text = " File Rec "
|
|
}
|
|
}
|
|
b.UiStatus.Text = text
|
|
b.Ui.Refresh()
|
|
}
|
|
|
|
func (b *Barnard) OnVoiceToggle(ui *uiterm.Ui, key uiterm.Key) {
|
|
b.setTransmit(ui, 2)
|
|
}
|
|
|
|
func (b *Barnard) OnSelfMuteToggle(ui *uiterm.Ui, key uiterm.Key) {
|
|
b.toggleSelfMute()
|
|
}
|
|
|
|
func (b *Barnard) OnSelfDeafenToggle(ui *uiterm.Ui, key uiterm.Key) {
|
|
b.toggleSelfDeafen()
|
|
}
|
|
|
|
func (b *Barnard) selfMuted() bool {
|
|
if b.Client == nil || b.Client.Self == nil {
|
|
return false
|
|
}
|
|
muted := false
|
|
b.Client.Do(func() { muted = b.Client.Self.SelfMuted })
|
|
return muted
|
|
}
|
|
|
|
func (b *Barnard) setSelfMute(muted bool) {
|
|
if !b.isConnected() || b.Client == nil || b.Client.Self == nil {
|
|
b.Notify("error", "me", "cannot change self mute while disconnected")
|
|
b.UpdateGeneralStatus("cannot change self mute while disconnected", true)
|
|
return
|
|
}
|
|
if !muted && b.selfDeafened() {
|
|
b.setUnmuteOnUndeafen(false)
|
|
b.Client.Self.SetSelfMutedAndDeafened(false, false)
|
|
b.Notify("unmuteundeafen", "me", "")
|
|
b.AddOutputLine("You unmuted and undeafened yourself")
|
|
return
|
|
}
|
|
if b.selfMuted() == muted {
|
|
return
|
|
}
|
|
if muted && b.isTransmitting() {
|
|
b.StopTransmission()
|
|
}
|
|
b.Client.Self.SetSelfMuted(muted)
|
|
event := "unmute"
|
|
line := "You unmuted yourself"
|
|
if muted {
|
|
event = "mute"
|
|
line = "You muted yourself"
|
|
}
|
|
b.Notify(event, "me", "")
|
|
b.AddOutputLine(line)
|
|
}
|
|
|
|
func (b *Barnard) toggleSelfMute() {
|
|
b.setSelfMute(!b.selfMuted())
|
|
}
|
|
|
|
func (b *Barnard) selfDeafened() bool {
|
|
if b.Client == nil || b.Client.Self == nil {
|
|
return false
|
|
}
|
|
deafened := false
|
|
b.Client.Do(func() { deafened = b.Client.Self.SelfDeafened })
|
|
return deafened
|
|
}
|
|
|
|
func (b *Barnard) setSelfDeafen(deafened bool) {
|
|
if !b.isConnected() || b.Client == nil || b.Client.Self == nil {
|
|
b.Notify("error", "me", "cannot change self deafen while disconnected")
|
|
b.UpdateGeneralStatus("cannot change self deafen while disconnected", true)
|
|
return
|
|
}
|
|
if b.selfDeafened() == deafened {
|
|
return
|
|
}
|
|
if deafened {
|
|
wasTransmitting := b.isTransmitting()
|
|
b.setTransmitOnUndeafen(wasTransmitting)
|
|
if wasTransmitting {
|
|
b.StopTransmission()
|
|
}
|
|
}
|
|
muted, unmuteOnUndeafen := selfDeafenTarget(deafened, b.selfMuted(), b.shouldUnmuteOnUndeafen())
|
|
b.setUnmuteOnUndeafen(unmuteOnUndeafen)
|
|
b.Client.Self.SetSelfMutedAndDeafened(muted, deafened)
|
|
if deafened {
|
|
if unmuteOnUndeafen {
|
|
b.Notify("mutedeafen", "me", "")
|
|
b.AddOutputLine("You muted and deafened yourself")
|
|
} else {
|
|
b.Notify("deafen", "me", "")
|
|
b.AddOutputLine("You deafened yourself")
|
|
}
|
|
return
|
|
}
|
|
if muted {
|
|
b.Notify("muteundeafen", "me", "")
|
|
b.AddOutputLine("You undeafened yourself; microphone remains muted")
|
|
} else {
|
|
b.Notify("unmuteundeafen", "me", "")
|
|
b.AddOutputLine("You unmuted and undeafened yourself")
|
|
}
|
|
}
|
|
|
|
func selfDeafenTarget(deafened, currentlyMuted, unmuteOnUndeafen bool) (muted, rememberUnmute bool) {
|
|
if deafened {
|
|
return true, !currentlyMuted
|
|
}
|
|
return !unmuteOnUndeafen, false
|
|
}
|
|
|
|
func (b *Barnard) toggleSelfDeafen() {
|
|
b.setSelfDeafen(!b.selfDeafened())
|
|
}
|
|
|
|
func (b *Barnard) CommandLog(ui *uiterm.Ui, cmd string) {
|
|
b.AddOutputLine("command " + cmd)
|
|
}
|
|
|
|
func (b *Barnard) CommandTalk(ui *uiterm.Ui, cmd string) {
|
|
b.setTransmit(ui, 2)
|
|
}
|
|
|
|
func (b *Barnard) CommandSelfMute(ui *uiterm.Ui, cmd string) {
|
|
b.toggleSelfMute()
|
|
}
|
|
|
|
func (b *Barnard) CommandSelfDeafen(ui *uiterm.Ui, cmd string) {
|
|
b.toggleSelfDeafen()
|
|
}
|
|
|
|
func (b *Barnard) CommandMicUp(ui *uiterm.Ui, cmd string) {
|
|
b.setTransmit(ui, 1)
|
|
}
|
|
|
|
func (b *Barnard) CommandMicDown(ui *uiterm.Ui, cmd string) {
|
|
b.setTransmit(ui, 0)
|
|
}
|
|
|
|
func (b *Barnard) CommandNoiseSuppressionToggle(ui *uiterm.Ui, cmd string) {
|
|
enabled := !b.UserConfig.GetNoiseSuppressionEnabled()
|
|
if err := b.UserConfig.SetNoiseSuppressionEnabled(enabled); err != nil {
|
|
b.AddOutputLine("Noise suppression: could not save setting: " + err.Error())
|
|
}
|
|
b.NoiseSuppressor.SetEnabled(enabled)
|
|
|
|
if enabled {
|
|
b.AddOutputLine("Noise suppression enabled")
|
|
} else {
|
|
b.AddOutputLine("Noise suppression disabled")
|
|
}
|
|
}
|
|
|
|
func (b *Barnard) CommandAGCToggle(ui *uiterm.Ui, cmd string) {
|
|
if b.toggleAGC() {
|
|
b.AddOutputLine("AGC enabled")
|
|
} else {
|
|
b.AddOutputLine("AGC disabled")
|
|
}
|
|
}
|
|
|
|
func (b *Barnard) CommandPlayFile(ui *uiterm.Ui, cmd string) {
|
|
// cmd contains just the filename part (everything after "/file ")
|
|
filename := strings.TrimSpace(cmd)
|
|
if filename == "" {
|
|
b.AddOutputLine("Usage: /file <filename or URL>")
|
|
return
|
|
}
|
|
|
|
// Check if it's a URL
|
|
isURL := strings.HasPrefix(filename, "http://") ||
|
|
strings.HasPrefix(filename, "https://") ||
|
|
strings.HasPrefix(filename, "ftp://") ||
|
|
strings.HasPrefix(filename, "rtmp://")
|
|
|
|
if !isURL {
|
|
// Expand ~ to home directory for local files
|
|
if strings.HasPrefix(filename, "~") {
|
|
homeDir := os.Getenv("HOME")
|
|
filename = strings.Replace(filename, "~", homeDir, 1)
|
|
}
|
|
|
|
// Check if local file exists
|
|
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
|
b.AddOutputLine(fmt.Sprintf("File not found: %s", filename))
|
|
return
|
|
}
|
|
}
|
|
|
|
if !b.isConnected() {
|
|
b.AddOutputLine("Not connected to server")
|
|
return
|
|
}
|
|
if b.ToneTest {
|
|
b.AddOutputLine("File playback is unavailable in tone test mode")
|
|
return
|
|
}
|
|
|
|
b.FileStreamMutex.Lock()
|
|
defer b.FileStreamMutex.Unlock()
|
|
if b.FileStream == nil {
|
|
b.AddOutputLine("File playback is unavailable while reconnecting")
|
|
return
|
|
}
|
|
|
|
if b.FileStream.IsPlaying() {
|
|
b.AddOutputLine("Already playing a file. Use /stop first.")
|
|
return
|
|
}
|
|
|
|
err := b.FileStream.PlayFile(filename)
|
|
if err != nil {
|
|
b.AddOutputLine(fmt.Sprintf("Error playing file: %s", err.Error()))
|
|
return
|
|
}
|
|
|
|
// Enable stereo encoder for file playback
|
|
b.Client.EnableStereoEncoder()
|
|
|
|
// Auto-start transmission if not already transmitting. FileStreamMutex is
|
|
// held here, before withStream's connection mutex, matching cleanup.
|
|
if !b.isTransmitting() {
|
|
var startErr error
|
|
started := b.withStream(func(stream *gumbleopenal.Stream) {
|
|
startErr = stream.StartSource(b.UserConfig.GetInputDevice())
|
|
})
|
|
if !started {
|
|
startErr = errors.New("audio unavailable while reconnecting")
|
|
}
|
|
if startErr != nil {
|
|
b.AddOutputLine(fmt.Sprintf("Error starting transmission: %s", startErr))
|
|
b.FileStream.Stop()
|
|
b.Client.DisableStereoEncoder()
|
|
return
|
|
}
|
|
b.setTransmitting(true)
|
|
b.UpdateGeneralStatus(" File ", true)
|
|
}
|
|
|
|
if isURL {
|
|
b.AddOutputLine(fmt.Sprintf("Streaming: %s (stereo)", filename))
|
|
} else {
|
|
b.AddOutputLine(fmt.Sprintf("Playing: %s (stereo)", filename))
|
|
}
|
|
}
|
|
|
|
func (b *Barnard) CommandStopFile(ui *uiterm.Ui, cmd string) {
|
|
b.FileStreamMutex.Lock()
|
|
defer b.FileStreamMutex.Unlock()
|
|
|
|
if b.FileStream == nil || !b.FileStream.IsPlaying() {
|
|
b.AddOutputLine("No file playing")
|
|
return
|
|
}
|
|
|
|
err := b.FileStream.Stop()
|
|
if err != nil {
|
|
b.AddOutputLine(fmt.Sprintf("Error stopping file: %s", err.Error()))
|
|
return
|
|
}
|
|
|
|
// Disable stereo encoder when file stops
|
|
b.Client.DisableStereoEncoder()
|
|
|
|
b.AddOutputLine("File playback stopped")
|
|
|
|
// Note: We keep transmission active even after file stops
|
|
// User can manually stop with talk key or it will stop when they're done talking
|
|
b.UpdateGeneralStatus(" Idle ", false)
|
|
}
|
|
|
|
func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
|
|
if b.isTransmitting() && val == 1 {
|
|
return
|
|
}
|
|
if !b.isTransmitting() && val == 0 {
|
|
return
|
|
}
|
|
if b.isTransmitting() {
|
|
b.Notify("micdown", "me", "")
|
|
b.setTransmitting(false)
|
|
b.UpdateGeneralStatus(" Idle ", false)
|
|
if b.ToneTest {
|
|
if b.toneTestStop != nil {
|
|
close(b.toneTestStop)
|
|
b.toneTestStop = nil
|
|
}
|
|
} else {
|
|
b.withStream(func(stream *gumbleopenal.Stream) { _ = stream.StopSource() })
|
|
}
|
|
} else if !b.isConnected() {
|
|
b.Notify("error", "me", "no tx while disconnected")
|
|
b.setTransmitting(false)
|
|
b.UpdateGeneralStatus("no tx while disconnected", true)
|
|
} else if b.isChannelMuted(b.Client.Self.Channel.ID) {
|
|
// Check if current channel is muted
|
|
b.Notify("error", "me", "cannot transmit in muted channel")
|
|
b.setTransmitting(false)
|
|
b.UpdateGeneralStatus("cannot transmit in muted channel", true)
|
|
} else if b.selfMuted() {
|
|
b.Notify("error", "me", "cannot transmit while self-muted")
|
|
b.setTransmitting(false)
|
|
b.UpdateGeneralStatus("cannot transmit while self-muted", true)
|
|
} else {
|
|
b.setTransmitting(true)
|
|
if b.ToneTest {
|
|
b.toneTestStop = make(chan struct{})
|
|
go StartToneGenerator(b.Client, b.toneTestStop)
|
|
b.Notify("micup", "me", "")
|
|
b.UpdateGeneralStatus(" Tx ", true)
|
|
} else {
|
|
started := b.withStream(func(stream *gumbleopenal.Stream) {
|
|
err := stream.StartSource(b.UserConfig.GetInputDevice())
|
|
if err != nil {
|
|
b.setTransmitting(false)
|
|
if fatalAudioOpenError(err) {
|
|
// A missing capture device cannot recover through normal
|
|
// transmission controls; exit so option 1 reports it on stderr.
|
|
b.exitWithError(fmt.Errorf("audio device initialization failed: %w", err))
|
|
return
|
|
}
|
|
b.Notify("error", "me", err.Error())
|
|
b.UpdateGeneralStatus(err.Error(), true)
|
|
return
|
|
}
|
|
b.Notify("micup", "me", "")
|
|
b.UpdateGeneralStatus(" Tx ", true)
|
|
})
|
|
if !started {
|
|
b.setTransmitting(false)
|
|
b.UpdateGeneralStatus("audio unavailable while reconnecting", true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func fatalAudioOpenError(err error) bool {
|
|
return errors.Is(err, gumbleopenal.ErrMic) || errors.Is(err, gumbleopenal.ErrInputDevice) || errors.Is(err, gumbleopenal.ErrOutputDevice)
|
|
}
|
|
|
|
func (b *Barnard) OnMicVolumeDown(ui *uiterm.Ui, key uiterm.Key) {
|
|
if b.ToneTest {
|
|
return
|
|
}
|
|
b.adjustMicVolume(-0.1)
|
|
}
|
|
|
|
func (b *Barnard) OnMicVolumeUp(ui *uiterm.Ui, key uiterm.Key) {
|
|
if b.ToneTest {
|
|
return
|
|
}
|
|
b.adjustMicVolume(0.1)
|
|
}
|
|
|
|
func (b *Barnard) adjustMicVolume(change float32) float32 {
|
|
return b.setMicVolume(b.UserConfig.GetMicVolume() + change)
|
|
}
|
|
|
|
func (b *Barnard) setMicVolume(volume float32) float32 {
|
|
if volume < 0 {
|
|
volume = 0
|
|
}
|
|
if volume > 1 {
|
|
volume = 1
|
|
}
|
|
b.UserConfig.SetMicVolume(volume)
|
|
b.withStream(func(stream *gumbleopenal.Stream) {
|
|
stream.SetMicVolume(volume, false)
|
|
})
|
|
if err := b.UserConfig.SaveConfig(); err != nil {
|
|
b.AddOutputLine("Microphone: could not save volume: " + err.Error())
|
|
}
|
|
return volume
|
|
}
|
|
|
|
func (b *Barnard) OnQuitPress(ui *uiterm.Ui, key uiterm.Key) {
|
|
b.shutdown()
|
|
}
|
|
|
|
func (b *Barnard) CommandExit(ui *uiterm.Ui, cmd string) {
|
|
b.shutdown()
|
|
}
|
|
|
|
// shutdown releases everything that outlives a single connection, including
|
|
// the tone test saver's output file, which reconnects deliberately keep open.
|
|
func (b *Barnard) shutdown() {
|
|
b.stopReconnects()
|
|
b.StopRecordingIfActive(true)
|
|
b.cleanupToneTestAudio()
|
|
b.Client.Disconnect()
|
|
b.Ui.Close()
|
|
}
|
|
|
|
func (b *Barnard) CommandStatus(ui *uiterm.Ui, cmd string) {
|
|
if b.isTransmitting() {
|
|
b.Notify("status", "me", "transmitting")
|
|
} else {
|
|
b.Notify("status", "me", "not transmitting")
|
|
}
|
|
}
|
|
|
|
func (b *Barnard) OnClearPress(ui *uiterm.Ui, key uiterm.Key) {
|
|
b.UiOutput.Clear()
|
|
}
|
|
|
|
func (b *Barnard) OnScrollOutputUp(ui *uiterm.Ui, key uiterm.Key) {
|
|
b.UiOutput.ScrollUp()
|
|
}
|
|
|
|
func (b *Barnard) OnScrollOutputDown(ui *uiterm.Ui, key uiterm.Key) {
|
|
b.UiOutput.ScrollDown()
|
|
}
|
|
|
|
func (b *Barnard) OnScrollOutputTop(ui *uiterm.Ui, key uiterm.Key) {
|
|
if !outputScrollAllowed(ui.Active()) {
|
|
return
|
|
}
|
|
b.UiOutput.ScrollTop()
|
|
}
|
|
|
|
func (b *Barnard) OnScrollOutputBottom(ui *uiterm.Ui, key uiterm.Key) {
|
|
if !outputScrollAllowed(ui.Active()) {
|
|
return
|
|
}
|
|
b.UiOutput.ScrollBottom()
|
|
}
|
|
|
|
func outputScrollAllowed(activeView string) bool {
|
|
return activeView != uiViewInput
|
|
}
|
|
|
|
func (b *Barnard) OnFocusPress(ui *uiterm.Ui, key uiterm.Key) {
|
|
active := b.Ui.Active()
|
|
if active == uiViewInput {
|
|
b.Ui.SetActive(uiViewTree)
|
|
} else if active == uiViewTree || active == uiViewAdmin {
|
|
b.Ui.SetActive(uiViewInput)
|
|
}
|
|
width, height := termbox.Size()
|
|
b.OnUiResize(ui, width, height)
|
|
ui.Refresh()
|
|
}
|
|
|
|
func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text string) {
|
|
if b.handleAdminPrompt(text) {
|
|
return
|
|
}
|
|
if text == "" {
|
|
return
|
|
}
|
|
|
|
// Check if this is a command (starts with /)
|
|
if strings.HasPrefix(text, "/") {
|
|
// Remove the leading slash and process it through the same command
|
|
// registry used by FIFO control.
|
|
cmdText := strings.TrimPrefix(text, "/")
|
|
if !ui.DispatchCommand(cmdText) {
|
|
cmdName, _, _ := strings.Cut(cmdText, " ")
|
|
b.AddOutputLine(fmt.Sprintf("Unknown command: /%s", cmdName))
|
|
}
|
|
return
|
|
}
|
|
|
|
// Not a command, send as chat message
|
|
if b.Client != nil && b.Client.Self != nil {
|
|
if selectedUser := b.selectedUserValue(); selectedUser != nil {
|
|
selectedUser.Send(text)
|
|
b.AddOutputPrivateMessage(b.Client.Self, selectedUser, text)
|
|
} else {
|
|
b.Client.Self.Channel.Send(text, false)
|
|
b.AddOutputMessage(b.Client.Self, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *Barnard) GotoChat() {
|
|
b.OnFocusPress(b.Ui, uiterm.KeyTab)
|
|
}
|
|
|
|
func (b *Barnard) OnUiDoneInitialize(ui *uiterm.Ui) {
|
|
b.start()
|
|
}
|
|
|
|
func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
|
|
ui.Add(uiViewLogo, &uiterm.Label{
|
|
Text: "Barnard ",
|
|
Fg: uiterm.ColorWhite | uiterm.AttrBold,
|
|
Bg: uiterm.ColorMagenta,
|
|
})
|
|
|
|
b.UiStatus = uiterm.Label{
|
|
Text: " Idle ",
|
|
Fg: uiterm.ColorBlack,
|
|
Bg: uiterm.ColorWhite,
|
|
}
|
|
b.statusText = b.UiStatus.Text
|
|
b.statusNotice = false
|
|
ui.Add(uiViewStatus, &b.UiStatus)
|
|
|
|
b.UiInput = uiterm.Textbox{
|
|
Fg: uiterm.ColorWhite,
|
|
Bg: uiterm.ColorBlack,
|
|
Input: b.OnTextInput,
|
|
}
|
|
ui.Add(uiViewInput, &b.UiInput)
|
|
|
|
b.UiInputStatus = uiterm.Label{
|
|
Text: "[root]",
|
|
Fg: uiterm.ColorBlack,
|
|
Bg: uiterm.ColorWhite,
|
|
}
|
|
ui.Add(uiViewInputStatus, &b.UiInputStatus)
|
|
|
|
b.UiOutput = uiterm.Textview{
|
|
Fg: uiterm.ColorWhite,
|
|
Bg: uiterm.ColorBlack,
|
|
}
|
|
ui.Add(uiViewOutput, &b.UiOutput)
|
|
|
|
b.UiTree = uiterm.Tree{
|
|
Generator: b.TreeItemBuild,
|
|
KeyListener: b.TreeItemKeyPress,
|
|
CharacterListener: b.TreeItemCharacter,
|
|
Fg: uiterm.ColorWhite,
|
|
Bg: uiterm.ColorBlack,
|
|
}
|
|
ui.Add(uiViewTree, &b.UiTree)
|
|
|
|
b.UiAdmin = uiterm.Tree{
|
|
Generator: b.AdminItemBuild,
|
|
KeyListener: b.AdminItemKeyPress,
|
|
CharacterListener: b.AdminItemCharacter,
|
|
Fg: uiterm.ColorWhite,
|
|
Bg: uiterm.ColorBlack,
|
|
}
|
|
ui.Add(uiViewAdmin, &b.UiAdmin)
|
|
|
|
b.Ui.AddCommandListener(b.CommandMicUp, "micup")
|
|
b.Ui.AddCommandListener(b.CommandMicDown, "micdown")
|
|
b.Ui.AddCommandListener(b.CommandTalk, "toggle")
|
|
b.Ui.AddCommandListener(b.CommandTalk, "talk")
|
|
b.Ui.AddCommandListener(b.CommandSelfMute, "mute")
|
|
b.Ui.AddCommandListener(b.CommandSelfDeafen, "deafen")
|
|
b.Ui.AddCommandListener(b.CommandExit, "exit")
|
|
b.Ui.AddCommandListener(b.CommandStatus, "status")
|
|
b.Ui.AddCommandListener(b.CommandNoiseSuppressionToggle, "noise")
|
|
b.Ui.AddCommandListener(b.CommandAGCToggle, "agc")
|
|
b.Ui.AddCommandListener(b.CommandPlayFile, "file")
|
|
b.Ui.AddCommandListener(b.CommandStopFile, "stop")
|
|
b.Ui.AddCommandListener(b.CommandRecord, "record")
|
|
b.Ui.AddCommandListener(b.CommandAdmin, "admin")
|
|
b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews)
|
|
b.Ui.AddKeyListener(b.OnAdminMenuPress, b.Hotkeys.AdminMenu)
|
|
b.Ui.AddKeyListener(b.OnHelpMenuPress, b.Hotkeys.HelpMenu)
|
|
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
|
|
b.Ui.AddKeyListener(b.OnSelfMuteToggle, b.Hotkeys.SelfMuteToggle)
|
|
b.Ui.AddKeyListener(b.OnSelfDeafenToggle, b.Hotkeys.SelfDeafenToggle)
|
|
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
|
|
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
|
|
b.Ui.AddKeyListener(b.OnAGCToggle, b.Hotkeys.AGCToggle)
|
|
b.Ui.AddKeyListener(b.OnRecordingToggle, b.Hotkeys.RecordToggle)
|
|
b.Ui.AddKeyListener(b.OnClearPress, b.Hotkeys.ClearOutput)
|
|
b.Ui.AddKeyListener(b.OnQuitPress, b.Hotkeys.Exit)
|
|
b.Ui.AddKeyListener(b.OnScrollOutputUp, b.Hotkeys.ScrollUp)
|
|
b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)
|
|
b.Ui.AddKeyListener(b.OnScrollOutputTop, b.Hotkeys.ScrollToTop)
|
|
b.Ui.AddKeyListener(b.OnScrollOutputBottom, b.Hotkeys.ScrollToBottom)
|
|
esc := uiterm.KeyEsc
|
|
b.Ui.AddKeyListener(b.OnAdminEscape, &esc)
|
|
b.Ui.SetActive(uiViewInput)
|
|
b.UiTree.Rebuild()
|
|
b.Ui.Refresh()
|
|
}
|
|
|
|
func (b *Barnard) OnUiResize(ui *uiterm.Ui, width, height int) {
|
|
treeHeight := 0
|
|
outputHeight := 0
|
|
active := b.Ui.Active()
|
|
if active == uiViewTree {
|
|
treeHeight = height - 4
|
|
outputHeight = 0
|
|
} else if active == uiViewAdmin {
|
|
treeHeight = 0
|
|
outputHeight = 0
|
|
} else {
|
|
treeHeight = 0
|
|
outputHeight = height - 4
|
|
}
|
|
ui.SetBounds(uiViewOutput, 0, 1, width, outputHeight+1)
|
|
ui.SetBounds(uiViewTree, 0, 1, width, treeHeight+1)
|
|
if active == uiViewAdmin {
|
|
ui.SetBounds(uiViewAdmin, 0, 1, width, height-3)
|
|
} else {
|
|
ui.SetBounds(uiViewAdmin, 0, 1, width, 1)
|
|
}
|
|
ui.SetBounds(uiViewStatus, 0, height-2, width, height-1)
|
|
ui.SetBounds(uiViewInputStatus, 0, height-1, len(b.GetInputStatus()), height)
|
|
ui.SetBounds(uiViewInput, len(b.GetInputStatus())+1, height-1, width, height)
|
|
}
|