Compare commits

...

10 Commits

Author SHA1 Message Date
Username
41f69052b5 add readline shortcuts to textbox (ctrl+w, ctrl+u, ctrl+k) 2026-02-24 10:02:09 +01:00
Username
c95736b632 suppress cgo compiler warnings in build output 2026-02-24 10:00:40 +01:00
Username
7ea5814c03 fix staticcheck S1005: remove unnecessary blank identifier 2026-02-24 09:56:44 +01:00
Username
f190e073ba add tab completion for slash commands 2026-02-24 09:54:07 +01:00
Username
72d549eade fix: clear self-mute when undeafening 2026-02-24 09:43:33 +01:00
Username
06eb85ee35 add text commands: /quit /exit /deafen /clear /help 2026-02-24 09:42:07 +01:00
Username
147c8a774d add deafen toggle (F1) and move voice toggle to F4 2026-02-24 09:36:56 +01:00
Username
5552874cd9 update README with all command-line options 2026-02-24 09:26:02 +01:00
Username
6d3146270d add configurable tree pane width and message logging 2026-02-24 09:25:41 +01:00
Username
fc1aaf354b add debug logging flag 2026-02-24 09:25:20 +01:00
8 changed files with 183 additions and 15 deletions

View File

@@ -2,13 +2,14 @@ PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
BINARY := barnard
GO ?= go
GOFLAGS ?=
GO ?= go
GOFLAGS ?=
export CGO_CFLAGS += -w -O2
.PHONY: build install uninstall clean fmt vet
build:
$(GO) build $(GOFLAGS) -o $(BINARY) .
@$(GO) build $(GOFLAGS) -o $(BINARY) .
install: build
install -d $(DESTDIR)$(BINDIR)

View File

@@ -29,11 +29,24 @@ variable (preferred over the `-password` flag, which is visible in process
listings). Use `-password-prompt` to read the password interactively from
stdin.
## Options
- `-server host[:port]` — server to connect to (default: `localhost:64738`)
- `-username name` — client username
- `-password pass` — server password (prefer `MUMBLE_PASSWORD` env var)
- `-password-prompt` — prompt for password on stdin
- `-certificate path` — PEM encoded certificate and private key
- `-insecure` — skip server certificate verification
- `-tree-width n` — width of the channel tree pane (default: `20`)
- `-log path` — append chat messages to file
- `-debug` — enable debug logging to stderr
## Manual
### Key bindings
- <kbd>F1</kbd>: toggle voice transmission
- <kbd>F1</kbd>: toggle deafen (mute + deaf)
- <kbd>F4</kbd>: toggle voice transmission
- <kbd>Ctrl+L</kbd>: clear chat log
- <kbd>Tab</kbd>: toggle focus between chat and user tree
- <kbd>Page Up</kbd>: scroll chat up

View File

@@ -2,6 +2,10 @@ package main
import (
"crypto/tls"
"fmt"
"log"
"os"
"time"
"layeh.com/barnard/uiterm"
"layeh.com/gumble/gumble"
@@ -14,8 +18,13 @@ type Barnard struct {
Address string
TLSConfig tls.Config
Debug bool
Logger *log.Logger
TreeWidth int
LogFile *os.File
Stream *gumbleopenal.Stream
Stream *gumbleopenal.Stream
transmitting bool
Ui *uiterm.Ui
UiOutput uiterm.Textview
@@ -24,3 +33,16 @@ type Barnard struct {
UiTree uiterm.Tree
UiInputStatus uiterm.Label
}
func (b *Barnard) debugf(format string, args ...interface{}) {
if b.Debug && b.Logger != nil {
b.Logger.Printf(format, args...)
}
}
func (b *Barnard) logMessage(line string) {
if b.LogFile != nil {
now := time.Now()
fmt.Fprintf(b.LogFile, "[%s] %s\n", now.Format("2006-01-02 15:04:05"), line)
}
}

View File

@@ -14,6 +14,7 @@ func (b *Barnard) start() {
b.Config.Attach(gumbleutil.AutoBitrate)
b.Config.Attach(b)
b.debugf("dialing %s", b.Address)
var err error
_, err = gumble.DialWithDialer(new(net.Dialer), b.Address, b.Config, &b.TLSConfig)
if err != nil {
@@ -25,6 +26,7 @@ func (b *Barnard) start() {
if os.Getenv("ALSOFT_LOGLEVEL") == "" {
os.Setenv("ALSOFT_LOGLEVEL", "0")
}
b.debugf("initializing audio stream")
if stream, err := gumbleopenal.New(b.Client); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
@@ -35,6 +37,7 @@ func (b *Barnard) start() {
func (b *Barnard) OnConnect(e *gumble.ConnectEvent) {
b.Client = e.Client
b.debugf("connected to %s", b.Client.Conn.RemoteAddr())
b.Ui.SetActive(uiViewInput)
b.UiTree.Rebuild()
@@ -48,6 +51,7 @@ func (b *Barnard) OnConnect(e *gumble.ConnectEvent) {
}
func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
b.debugf("disconnected (type=%d)", e.Type)
var reason string
switch e.Type {
case gumble.DisconnectError:
@@ -63,10 +67,14 @@ func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
}
func (b *Barnard) OnTextMessage(e *gumble.TextMessageEvent) {
if e.Sender != nil {
b.debugf("message from %s (%d bytes)", e.Sender.Name, len(e.Message))
}
b.AddOutputMessage(e.Sender, e.Message)
}
func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
b.debugf("user change: %s (type=%d)", e.User.Name, e.Type)
if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self {
b.UpdateInputStatus(fmt.Sprintf("To: %s", e.User.Channel.Name))
}
@@ -80,6 +88,7 @@ func (b *Barnard) OnChannelChange(e *gumble.ChannelChangeEvent) {
}
func (b *Barnard) OnPermissionDenied(e *gumble.PermissionDeniedEvent) {
b.debugf("permission denied (type=%d)", e.Type)
var info string
switch e.Type {
case gumble.PermissionDeniedOther:

19
main.go
View File

@@ -4,6 +4,7 @@ import (
"crypto/tls"
"flag"
"fmt"
"log"
"net"
"os"
@@ -23,6 +24,9 @@ func main() {
passwordPrompt := flag.Bool("password-prompt", false, "prompt for server password on stdin")
insecure := flag.Bool("insecure", false, "skip server certificate verification")
certificate := flag.String("certificate", "", "PEM encoded certificate and private key")
debug := flag.Bool("debug", false, "enable debug logging to stderr")
treeWidth := flag.Int("tree-width", 20, "width of the channel tree pane")
logFile := flag.String("log", "", "write chat messages to file")
flag.Parse()
@@ -56,6 +60,21 @@ func main() {
b.Config.Username = *username
b.Config.Password = pass
b.TreeWidth = *treeWidth
b.Debug = *debug
if b.Debug {
b.Logger = log.New(os.Stderr, "barnard: ", log.Ltime)
b.debugf("connecting to %s as %q", addr, *username)
}
if *logFile != "" {
f, err := os.OpenFile(*logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
defer f.Close()
b.LogFile = f
}
if *insecure {
b.TLSConfig.InsecureSkipVerify = true

107
ui.go
View File

@@ -33,6 +33,7 @@ func (b *Barnard) UpdateInputStatus(status string) {
func (b *Barnard) AddOutputLine(line string) {
now := time.Now()
b.UiOutput.AddLine(fmt.Sprintf("[%02d:%02d:%02d] %s", now.Hour(), now.Minute(), now.Second(), line))
b.logMessage(line)
}
func (b *Barnard) AddOutputMessage(sender *gumble.User, message string) {
@@ -43,18 +44,59 @@ func (b *Barnard) AddOutputMessage(sender *gumble.User, message string) {
}
}
func (b *Barnard) OnVoiceToggle(ui *uiterm.Ui, key uiterm.Key) {
if b.UiStatus.Text == " Tx " {
func (b *Barnard) updateStatus() {
if b.Client == nil || b.Client.Self == nil {
return
}
if b.Client.Self.SelfDeafened {
b.UiStatus.Text = " Deaf "
b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
b.UiStatus.Bg = uiterm.ColorBlue
} else if b.transmitting {
b.UiStatus.Text = " Tx "
b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
b.UiStatus.Bg = uiterm.ColorRed
} else {
b.UiStatus.Text = " Idle "
b.UiStatus.Fg = uiterm.ColorBlack
b.UiStatus.Bg = uiterm.ColorWhite
}
}
func (b *Barnard) OnDeafenToggle(ui *uiterm.Ui, key uiterm.Key) {
if b.Client == nil || b.Client.Self == nil {
return
}
deaf := !b.Client.Self.SelfDeafened
b.Client.Self.SetSelfDeafened(deaf)
if deaf && b.transmitting {
b.transmitting = false
b.Stream.StopSource()
}
if !deaf {
b.Client.Self.SetSelfMuted(false)
}
b.debugf("deafen=%v", deaf)
b.updateStatus()
ui.Refresh()
}
func (b *Barnard) OnVoiceToggle(ui *uiterm.Ui, key uiterm.Key) {
if b.Client == nil || b.Client.Self == nil {
return
}
if b.Client.Self.SelfDeafened {
return
}
if b.transmitting {
b.transmitting = false
b.Stream.StopSource()
} else {
b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold
b.UiStatus.Bg = uiterm.ColorRed
b.UiStatus.Text = " Tx "
b.transmitting = true
b.Stream.StartSource()
}
b.debugf("transmit=%v", b.transmitting)
b.updateStatus()
ui.Refresh()
}
@@ -83,8 +125,14 @@ func (b *Barnard) OnScrollOutputBottom(ui *uiterm.Ui, key uiterm.Key) {
b.UiOutput.ScrollBottom()
}
var commands = []string{"/clear", "/deafen", "/exit", "/help", "/quit"}
func (b *Barnard) OnFocusPress(ui *uiterm.Ui, key uiterm.Key) {
active := b.Ui.Active()
if active == uiViewInput && strings.HasPrefix(b.UiInput.Text, "/") {
b.completeCommand()
return
}
if active == uiViewInput {
b.Ui.SetActive(uiViewTree)
} else if active == uiViewTree {
@@ -92,10 +140,51 @@ func (b *Barnard) OnFocusPress(ui *uiterm.Ui, key uiterm.Key) {
}
}
func (b *Barnard) completeCommand() {
text := b.UiInput.Text
var matches []string
for _, cmd := range commands {
if strings.HasPrefix(cmd, text) {
matches = append(matches, cmd)
}
}
if len(matches) == 0 {
return
}
if len(matches) == 1 {
b.UiInput.Text = matches[0]
} else {
prefix := matches[0]
for _, m := range matches[1:] {
for !strings.HasPrefix(m, prefix) {
prefix = prefix[:len(prefix)-1]
}
}
if len(prefix) > len(text) {
b.UiInput.Text = prefix
}
}
b.Ui.Refresh()
}
func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text string) {
if text == "" {
return
}
switch strings.TrimSpace(text) {
case "/quit", "/exit":
b.OnQuitPress(ui, 0)
return
case "/deafen":
b.OnDeafenToggle(ui, 0)
return
case "/clear":
b.OnClearPress(ui, 0)
return
case "/help":
b.AddOutputLine("/quit /exit /deafen /clear /help")
return
}
if b.Client != nil && b.Client.Self != nil {
b.Client.Self.Channel.Send(text, false)
b.AddOutputMessage(b.Client.Self, text)
@@ -149,7 +238,8 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
ui.Add(uiViewTree, &b.UiTree)
b.Ui.AddKeyListener(b.OnFocusPress, uiterm.KeyTab)
b.Ui.AddKeyListener(b.OnVoiceToggle, uiterm.KeyF1)
b.Ui.AddKeyListener(b.OnDeafenToggle, uiterm.KeyF1)
b.Ui.AddKeyListener(b.OnVoiceToggle, uiterm.KeyF4)
b.Ui.AddKeyListener(b.OnQuitPress, uiterm.KeyF10)
b.Ui.AddKeyListener(b.OnClearPress, uiterm.KeyCtrlL)
b.Ui.AddKeyListener(b.OnScrollOutputUp, uiterm.KeyPgup)
@@ -161,11 +251,12 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
}
func (b *Barnard) OnUiResize(ui *uiterm.Ui, width, height int) {
tw := b.TreeWidth
ui.SetBounds(uiViewLogo, 0, 0, 9, 1)
ui.SetBounds(uiViewTop, 9, 0, width-6, 1)
ui.SetBounds(uiViewStatus, width-6, 0, width, 1)
ui.SetBounds(uiViewInput, 0, height-1, width, height)
ui.SetBounds(uiViewInputStatus, 0, height-2, width, height-1)
ui.SetBounds(uiViewOutput, 0, 1, width-20, height-2)
ui.SetBounds(uiViewTree, width-20, 1, width, height-2)
ui.SetBounds(uiViewOutput, 0, 1, width-tw, height-2)
ui.SetBounds(uiViewTree, width-tw, 1, width, height-2)
}

View File

@@ -63,9 +63,12 @@ func (t *Textbox) uiDraw() {
func (t *Textbox) uiKeyEvent(mod Modifier, key Key) {
redraw := false
switch key {
case KeyCtrlC:
case KeyCtrlC, KeyCtrlU, KeyCtrlK:
t.Text = ""
redraw = true
case KeyCtrlW:
t.Text = deleteWord(t.Text)
redraw = true
case KeyEnter:
if t.Input != nil {
t.Input(t.ui, t, t.Text)
@@ -91,6 +94,16 @@ func (t *Textbox) uiKeyEvent(mod Modifier, key Key) {
}
}
func deleteWord(s string) string {
// Trim trailing spaces, then trim non-spaces (the word)
s = strings.TrimRight(s, " ")
i := strings.LastIndex(s, " ")
if i < 0 {
return ""
}
return s[:i+1]
}
func (t *Textbox) uiCharacterEvent(chr rune) {
if len(t.Text) >= maxTextboxLen {
return

View File

@@ -80,7 +80,7 @@ func (ui *Ui) Active() string {
}
func (ui *Ui) SetActive(name string) {
element, _ := ui.elements[name]
element := ui.elements[name]
if ui.activeElement != nil {
ui.activeElement.View.uiSetActive(false)
}