Redesign default tree controls

This commit is contained in:
Storm Dragon
2026-09-05 00:34:40 -04:00
parent db2d441dfc
commit ef19a558d2
7 changed files with 160 additions and 31 deletions
+9 -6
View File
@@ -223,12 +223,13 @@ When in the message input box:
* enter submits the entered message
When in the treeview, pressing:
* f5 or f6 on a channel changes the volume for all users in that channel
* f5 or f6 on a user changes the volume for that user.
* left or right arrow on a channel changes the volume for all users in that channel
* left or right arrow on a user changes the volume for that user.
* backspace restores the focused user, or every user in the focused channel, to unmuted, 100% volume, and normal boost.
* enter on de-selected user selects that user for PM mode.
* enter on selected user de-selects the user
* enter on a channel de-selects any selected users (if any) and moves you to the specified channel.
* f11 opens the actions menu for the focused user or channel.
* f10 opens the actions menu for the focused user or channel.
The actions menu is a plain tree view for screen reader accessibility. It starts with non-privileged information actions, such as requesting user comments, user stats, channel descriptions, and channel permissions. If Barnard has permission information showing that you cannot perform an admin action, that privileged action is hidden. The server still remains the final authority and will reject actions if permissions are missing or stale. Destructive actions such as kick, ban, channel deletion, deregister, and raw ACL edits prompt in the input line before sending. Press Escape to close the menu or cancel an active prompt.
@@ -250,7 +251,7 @@ Admin actions are also available through `/admin` commands and the FIFO command
## Volume
If you set the volume for a user (using the F5/F6 keys by default), Barnard will remember that volume, and will keep that user at that volume.
If you set the volume for a user (using the left/right arrow keys by default), Barnard will remember that volume, and will keep that user at that volume.
The volume is set for a single user on a single server.
This means you may have to set a person to a custom volume multiple times, if you are both on multiple servers together.
@@ -302,7 +303,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- <kbd>F1</kbd>: toggle voice transmission
- <kbd>F9</kbd>: toggle noise suppression
- <kbd>F12</kbd>: toggle automatic gain control
- <kbd>F11</kbd>: open actions menu for the focused tree item
- <kbd>F10</kbd>: open actions menu for the focused tree item
- <kbd>Ctrl+R</kbd>: toggle recording
- <kbd>Ctrl+L</kbd>: clear chat log
- <kbd>Tab</kbd>: toggle focus between chat and user tree
@@ -310,7 +311,9 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- <kbd>Page Down</kbd>: scroll chat down
- <kbd>Home</kbd>: scroll chat to the top
- <kbd>End</kbd>: scroll chat to the bottom
- <kbd>F10</kbd>: quit
- <kbd>Ctrl+Q</kbd>: quit
With the user/channel tree focused, Left/Right changes incoming volume and Backspace restores the focused user or channel to unmuted, 100% volume, and normal boost. In the message input, those keys retain their normal text-editing behavior.
## License
+14 -2
View File
@@ -253,6 +253,17 @@ func (b *Barnard) StopTransmission() {
func (b *Barnard) TreeItemCharacter(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, ch rune) {
}
func matchesVolumeResetKey(configured *uiterm.Key, pressed uiterm.Key) bool {
if configured == nil {
return false
}
if pressed == *configured {
return true
}
return (*configured == uiterm.KeyBackspace || *configured == uiterm.KeyBackspace2) &&
(pressed == uiterm.KeyBackspace || pressed == uiterm.KeyBackspace2)
}
func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, key uiterm.Key) {
treeItem := item.(TreeItem)
if key == uiterm.KeyEnter {
@@ -310,8 +321,9 @@ func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm
if key == *b.Hotkeys.VolumeUp {
b.changeVolume(makeUsersArray(treeItem.Channel.Users), 0.1)
}
if key == *b.Hotkeys.VolumeReset {
if matchesVolumeResetKey(b.Hotkeys.VolumeReset, key) {
b.resetVolume(makeUsersArray(treeItem.Channel.Users))
b.setChannelMuted(treeItem.Channel.ID, false)
}
}
@@ -331,7 +343,7 @@ func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm
if key == *b.Hotkeys.VolumeUp {
b.changeVolume([]*gumble.User{treeItem.User}, 0.1)
}
if key == *b.Hotkeys.VolumeReset {
if matchesVolumeResetKey(b.Hotkeys.VolumeReset, key) {
b.resetVolume([]*gumble.User{treeItem.User})
}
}
+30 -10
View File
@@ -95,12 +95,12 @@ func (c *Config) LoadConfig() {
jc = exportableConfig{}
jc.Hotkeys = &Hotkeys{
Talk: key(uiterm.KeyF1),
VolumeDown: key(uiterm.KeyF5),
VolumeUp: key(uiterm.KeyF6),
VolumeReset: key(uiterm.KeyF8),
VolumeDown: key(uiterm.KeyArrowLeft),
VolumeUp: key(uiterm.KeyArrowRight),
VolumeReset: key(uiterm.KeyBackspace),
MuteToggle: key(uiterm.KeyF7), // Added mute toggle hotkey
RecordToggle: key(uiterm.KeyCtrlR),
Exit: key(uiterm.KeyF10),
Exit: key(uiterm.KeyCtrlQ),
ToggleTimestamps: key(uiterm.KeyF3),
SwitchViews: key(uiterm.KeyTab),
ClearOutput: key(uiterm.KeyCtrlL),
@@ -108,7 +108,7 @@ func (c *Config) LoadConfig() {
ScrollDown: key(uiterm.KeyPgdn),
ScrollToTop: key(uiterm.KeyHome),
ScrollToBottom: key(uiterm.KeyEnd),
AdminMenu: key(uiterm.KeyF11),
AdminMenu: key(uiterm.KeyF10),
NoiseSuppressionToggle: key(uiterm.KeyF9),
AGCToggle: key(uiterm.KeyF12),
}
@@ -182,12 +182,12 @@ func (c *Config) ensureHotkeys() {
}
defaults := Hotkeys{
Talk: key(uiterm.KeyF1),
VolumeDown: key(uiterm.KeyF5),
VolumeUp: key(uiterm.KeyF6),
VolumeReset: key(uiterm.KeyF8),
VolumeDown: key(uiterm.KeyArrowLeft),
VolumeUp: key(uiterm.KeyArrowRight),
VolumeReset: key(uiterm.KeyBackspace),
MuteToggle: key(uiterm.KeyF7),
RecordToggle: key(uiterm.KeyCtrlR),
Exit: key(uiterm.KeyF10),
Exit: key(uiterm.KeyCtrlQ),
ToggleTimestamps: key(uiterm.KeyF3),
SwitchViews: key(uiterm.KeyTab),
ClearOutput: key(uiterm.KeyCtrlL),
@@ -195,7 +195,7 @@ func (c *Config) ensureHotkeys() {
ScrollDown: key(uiterm.KeyPgdn),
ScrollToTop: key(uiterm.KeyHome),
ScrollToBottom: key(uiterm.KeyEnd),
AdminMenu: key(uiterm.KeyF11),
AdminMenu: key(uiterm.KeyF10),
NoiseSuppressionToggle: key(uiterm.KeyF9),
AGCToggle: key(uiterm.KeyF12),
}
@@ -309,6 +309,26 @@ func (c *Config) ToggleMute(u *gumble.User) error {
return c.saveConfigLocked()
}
// ResetUserAudio restores and saves the default incoming audio settings for
// each user on the given server.
func (c *Config) ResetUserAudio(address string, users []*gumble.User) error {
c.mu.Lock()
defer c.mu.Unlock()
for _, u := range users {
if u == nil {
continue
}
storedUser := c.findUser(address, u.Name)
storedUser.Boost = 1
storedUser.Volume = 1.0
storedUser.LocallyMuted = false
u.SetBoost(1)
u.SetVolume(1.0)
u.SetLocallyMuted(false)
}
return c.saveConfigLocked()
}
func (c *Config) SetMicVolume(v float32) {
c.mu.Lock()
defer c.mu.Unlock()
+63 -2
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/uiterm"
)
@@ -51,8 +52,8 @@ func TestConfigBackfillsRecordingDefaults(t *testing.T) {
if cfg.GetHotkeys().AdminMenu == nil {
t.Fatal("expected admin menu hotkey to be backfilled")
}
if got := *cfg.GetHotkeys().AdminMenu; got != uiterm.KeyF11 {
t.Fatalf("expected admin menu f11, got %s", got)
if got := *cfg.GetHotkeys().AdminMenu; got != uiterm.KeyF10 {
t.Fatalf("expected admin menu f10, got %s", got)
}
for name, got := range map[string]*uiterm.Key{
"clear output": cfg.GetHotkeys().ClearOutput,
@@ -74,6 +75,66 @@ func TestConfigBackfillsRecordingDefaults(t *testing.T) {
}
}
func TestNewHotkeyDefaults(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "barnard.toml")
cfg := NewConfig(&configPath)
if err := cfg.SaveConfig(); err != nil {
t.Fatal(err)
}
hotkeys := NewConfig(&configPath).GetHotkeys()
tests := []struct {
name string
got *uiterm.Key
want uiterm.Key
}{
{name: "volume down", got: hotkeys.VolumeDown, want: uiterm.KeyArrowLeft},
{name: "volume up", got: hotkeys.VolumeUp, want: uiterm.KeyArrowRight},
{name: "volume reset", got: hotkeys.VolumeReset, want: uiterm.KeyBackspace},
{name: "actions menu", got: hotkeys.AdminMenu, want: uiterm.KeyF10},
{name: "exit", got: hotkeys.Exit, want: uiterm.KeyCtrlQ},
}
for _, tt := range tests {
if tt.got == nil || *tt.got != tt.want {
t.Errorf("%s default = %v, want %s", tt.name, tt.got, tt.want)
}
}
}
func TestExplicitLegacyHotkeysRemainConfigured(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "barnard.toml")
data := []byte("[hotkeys]\nvolumedown = \"f5\"\nvolumeup = \"f6\"\nvolumereset = \"f8\"\nadminmenu = \"f11\"\nexit = \"f10\"\n")
if err := os.WriteFile(configPath, data, 0600); err != nil {
t.Fatal(err)
}
hotkeys := NewConfig(&configPath).GetHotkeys()
if *hotkeys.VolumeDown != uiterm.KeyF5 || *hotkeys.VolumeUp != uiterm.KeyF6 ||
*hotkeys.VolumeReset != uiterm.KeyF8 || *hotkeys.AdminMenu != uiterm.KeyF11 ||
*hotkeys.Exit != uiterm.KeyF10 {
t.Fatalf("explicit legacy hotkeys were replaced: %+v", hotkeys)
}
}
func TestResetUserAudioRestoresAndPersistsDefaults(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "barnard.toml")
cfg := NewConfig(&configPath)
user := &gumble.User{Name: "Username"}
user.SetBoost(4)
user.SetVolume(0.4)
user.SetLocallyMuted(true)
if err := cfg.ResetUserAudio("example.test:64738", []*gumble.User{user}); err != nil {
t.Fatal(err)
}
if user.Boost() != 1 || user.Volume() != 1.0 || user.LocallyMuted() {
t.Fatalf("runtime audio was not reset: boost=%d volume=%v muted=%v", user.Boost(), user.Volume(), user.LocallyMuted())
}
storedUser := cfg.findUser("example.test:64738", user.Name)
if storedUser.Boost != 1 || storedUser.Volume != 1.0 || storedUser.LocallyMuted {
t.Fatalf("saved audio was not reset: %+v", storedUser)
}
}
func TestAGCDefaultsOnAndPersists(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "barnard.toml")
if err := os.WriteFile(configPath, []byte("[hotkeys]\ntalk = \"f1\"\n"), 0600); err != nil {
+9 -11
View File
@@ -69,21 +69,19 @@ func (b *Barnard) changeVolume(users []*gumble.User, change float32) {
}
func (b *Barnard) resetVolume(users []*gumble.User) {
changed := b.withStream(func(stream *gumbleopenal.Stream) {
if b.Client == nil || b.Client.Config == nil {
return
}
if err := b.UserConfig.ResetUserAudio(b.Client.Config.Address, users); err != nil {
b.AddOutputLine("Volume: could not save reset: " + err.Error())
return
}
b.withStream(func(stream *gumbleopenal.Stream) {
for _, u := range users {
// Reset to original volume (1.0) and boost (1)
u.SetBoost(uint16(1))
u.SetVolume(1.0)
stream.UpdateUserGain(u)
b.UserConfig.UpdateConfig(u)
}
if err := b.UserConfig.SaveConfig(); err != nil {
b.AddOutputLine("Volume: could not save setting: " + err.Error())
}
})
if changed {
b.refreshVolumeDisplay()
}
b.refreshVolumeDisplay()
}
// Tree items render a display string snapshotted at build time, so a volume
+14
View File
@@ -4,8 +4,22 @@ import (
"testing"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/uiterm"
)
func TestMatchesVolumeResetKeyAcceptsBothTerminalBackspaces(t *testing.T) {
configured := uiterm.KeyBackspace
if !matchesVolumeResetKey(&configured, uiterm.KeyBackspace) {
t.Fatal("configured backspace did not match itself")
}
if !matchesVolumeResetKey(&configured, uiterm.KeyBackspace2) {
t.Fatal("configured backspace did not match delete/127 representation")
}
if matchesVolumeResetKey(&configured, uiterm.KeyDelete) {
t.Fatal("delete key unexpectedly matched volume reset")
}
}
// Regression: rebuilding the channel tree ranged protocol-owned maps without
// Client.Do while TCP handlers could add or remove users/channels.
func TestTreeItemBuildReadsMapsUnderClientSnapshot(t *testing.T) {
+21
View File
@@ -1,6 +1,7 @@
package uiterm
import (
"reflect"
"testing"
"time"
)
@@ -15,6 +16,26 @@ func (i *cyclicItem) TreeItemStyle(fg, bg Attribute, active bool) (Attribute, At
return fg, bg
}
func TestTreePassesLeftRightAndBackspaceToFocusedItem(t *testing.T) {
item := &cyclicItem{name: "user"}
var received []Key
tree := Tree{
KeyListener: func(_ *Ui, _ *Tree, _ TreeItem, key Key) {
received = append(received, key)
},
lines: []renderedTreeItem{{Item: item}},
}
tree.uiInitialize(New(nil))
for _, key := range []Key{KeyArrowLeft, KeyArrowRight, KeyBackspace, KeyBackspace2} {
tree.uiKeyEvent(key)
}
want := []Key{KeyArrowLeft, KeyArrowRight, KeyBackspace, KeyBackspace2}
if !reflect.DeepEqual(received, want) {
t.Fatalf("tree keys = %v, want %v", received, want)
}
}
// Regression: rebuild_rec followed parent/child links with no depth limit, so
// a cyclic channel graph recursed until the process ran out of memory. A
// rebuild must now terminate and stay bounded.