From ef19a558d22636e49be46d28ba2541313b3d0181 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 5 Sep 2026 00:34:40 -0400 Subject: [PATCH] Redesign default tree controls --- README.md | 15 ++++---- barnard.go | 16 +++++++-- config/user_config.go | 40 +++++++++++++++------ config/user_config_test.go | 65 ++++++++++++++++++++++++++++++++-- ui_tree.go | 20 +++++------ ui_tree_test.go | 14 ++++++++ uiterm/tree_regression_test.go | 21 +++++++++++ 7 files changed, 160 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 38ec942..d2507fc 100644 --- a/README.md +++ b/README.md @@ -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) - F1: toggle voice transmission - F9: toggle noise suppression - F12: toggle automatic gain control -- F11: open actions menu for the focused tree item +- F10: open actions menu for the focused tree item - Ctrl+R: toggle recording - Ctrl+L: clear chat log - Tab: toggle focus between chat and user tree @@ -310,7 +311,9 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH) - Page Down: scroll chat down - Home: scroll chat to the top - End: scroll chat to the bottom -- F10: quit +- Ctrl+Q: 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 diff --git a/barnard.go b/barnard.go index 4f1e303..a6c636a 100644 --- a/barnard.go +++ b/barnard.go @@ -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}) } } diff --git a/config/user_config.go b/config/user_config.go index 5cdfb22..80a87f8 100644 --- a/config/user_config.go +++ b/config/user_config.go @@ -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() diff --git a/config/user_config_test.go b/config/user_config_test.go index 0be9623..4b6f1c3 100644 --- a/config/user_config_test.go +++ b/config/user_config_test.go @@ -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 { diff --git a/ui_tree.go b/ui_tree.go index 04af22e..3db51b0 100644 --- a/ui_tree.go +++ b/ui_tree.go @@ -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 diff --git a/ui_tree_test.go b/ui_tree_test.go index 69db060..6d339d3 100644 --- a/ui_tree_test.go +++ b/ui_tree_test.go @@ -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) { diff --git a/uiterm/tree_regression_test.go b/uiterm/tree_regression_test.go index 15bacea..4feca38 100644 --- a/uiterm/tree_regression_test.go +++ b/uiterm/tree_regression_test.go @@ -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.