diff --git a/README.md b/README.md index 0c01d25..ef29ac8 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,8 @@ Leaving `audiodriver` empty uses the OpenAL Soft default ordering from the insta You can see the below keystrokes in your config file. +Press F1 for keyboard help generated from the bindings currently loaded from your config file. Press F10 for focused user/channel actions and audio settings. + Pressing tab inside the main window switches between the user/channel tree view and the message input box. When in the message input box: * left and right arrow keys move by character @@ -302,21 +304,24 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH) ### Key bindings - Ctrl+T: toggle voice transmission +- F1: open keyboard help - Alt+M: toggle your Mumble self-mute state from anywhere - F9: toggle noise suppression - F12: toggle automatic gain control -- F10: open actions menu for the focused tree item +- F10: open actions and settings for the focused tree item - Ctrl+R: toggle recording - Ctrl+L: clear chat log - Tab: toggle focus between chat and user tree - Page Up: scroll chat up - Page Down: scroll chat down -- Home: scroll chat to the top -- End: scroll chat to the bottom +- Home: scroll chat to the top outside the message input +- End: scroll chat to the bottom outside the message input - Ctrl+Q: quit With the user/channel tree focused, M locally mutes the focused user or channel, Left/Right changes incoming volume, and Backspace restores the focused user or channel to unmuted, 100% volume, and normal boost. M on your own user toggles self-mute. In the message input, printable letters and editing keys retain their normal behavior. +Home and End move to the beginning and end of text while the message input is focused. Outside the message input, they jump the chat log to its oldest and newest entries. + ## License GPLv2 diff --git a/admin.go b/admin.go index 52d0500..7d064f3 100644 --- a/admin.go +++ b/admin.go @@ -18,9 +18,10 @@ type adminPrompt struct { } type adminItem struct { - label string - children []uiterm.TreeItem - action func() + label string + children []uiterm.TreeItem + action func() + applicationAction bool } func (i adminItem) String() string { @@ -41,6 +42,10 @@ func (b *Barnard) OnAdminMenuPress(ui *uiterm.Ui, key uiterm.Key) { b.OpenAdminMenu() } +func (b *Barnard) OnHelpMenuPress(ui *uiterm.Ui, key uiterm.Key) { + b.OpenKeyboardHelp() +} + func (b *Barnard) OnAdminEscape(ui *uiterm.Ui, key uiterm.Key) { if b.pendingAdminPrompt != nil { b.pendingAdminPrompt = nil @@ -64,6 +69,8 @@ func (b *Barnard) OpenAdminMenu() { b.AddOutputLine("Admin: not connected") return } + b.keyboardHelpOpen = false + b.adminReturnView = b.Ui.Active() b.adminReturnItem = b.UiTree.ActiveItem() b.adminTargetUser = b.selectedUserValue() b.adminTargetChan = b.Client.Self.Channel @@ -103,9 +110,17 @@ func (b *Barnard) OpenAdminMenu() { } func (b *Barnard) CloseAdminMenu(ui *uiterm.Ui) { - b.Ui.SetActive(uiViewTree) - b.UiTree.SetActiveItem(b.adminReturnItem, sameUserChannelTreeItem) + b.keyboardHelpOpen = false + returnView := b.adminReturnView + if returnView != uiViewInput && returnView != uiViewTree { + returnView = uiViewTree + } + b.Ui.SetActive(returnView) + if returnView == uiViewTree { + b.UiTree.SetActiveItem(b.adminReturnItem, sameUserChannelTreeItem) + } b.adminReturnItem = nil + b.adminReturnView = "" width, height := termboxSize() b.OnUiResize(ui, width, height) ui.Refresh() @@ -123,6 +138,9 @@ func (b *Barnard) AdminItemBuild(item uiterm.TreeItem) []uiterm.TreeItem { } return nil } + if b.keyboardHelpOpen { + return b.keyboardHelpItems() + } target := "server" if b.adminTargetUser != nil { @@ -133,6 +151,10 @@ func (b *Barnard) AdminItemBuild(item uiterm.TreeItem) []uiterm.TreeItem { items := []uiterm.TreeItem{ adminItem{label: "Action target: " + target}, + adminItem{label: "Audio settings", children: b.audioSettingsItems()}, + adminItem{label: "Open keyboard help", action: func() { + b.keyboardHelpOpen = true + }, applicationAction: true}, adminItem{label: "Information actions", children: b.informationItems()}, } if children := b.adminUserItems(); len(children) > 0 { @@ -153,10 +175,118 @@ func (b *Barnard) AdminItemBuild(item uiterm.TreeItem) []uiterm.TreeItem { if children := b.adminContextActionItems(); len(children) > 0 { items = append(items, adminItem{label: "Context actions", children: children}) } - items = append(items, adminItem{label: "Close actions menu", action: b.closeAdminAction}) + items = append(items, adminItem{label: "Close actions menu", action: b.closeAdminAction, applicationAction: true}) return items } +func (b *Barnard) OpenKeyboardHelp() { + b.keyboardHelpOpen = true + b.adminReturnView = b.Ui.Active() + b.adminReturnItem = b.UiTree.ActiveItem() + b.UiAdmin.Rebuild() + b.Ui.SetActive(uiViewAdmin) + width, height := termboxSize() + b.OnUiResize(b.Ui, width, height) + b.AddOutputLine("Help: keyboard shortcuts opened") + b.Ui.Refresh() +} + +func (b *Barnard) audioSettingsItems() []uiterm.TreeItem { + agc := enabledLabel(b.UserConfig.GetAGCEnabled()) + noiseSuppression := enabledLabel(b.UserConfig.GetNoiseSuppressionEnabled()) + micVolume := b.UserConfig.GetMicVolume() + return []uiterm.TreeItem{ + adminItem{label: "Automatic gain control: " + agc + "; Enter toggles", applicationAction: true, action: func() { + enabled := b.toggleAGC() + b.AddOutputLine("Settings: automatic gain control " + enabledLabel(enabled)) + b.UpdateGeneralStatus("Automatic gain control: "+enabledLabel(enabled), false) + }}, + adminItem{label: "Noise suppression: " + noiseSuppression + "; Enter toggles", applicationAction: true, action: func() { + enabled := b.toggleNoiseSuppression() + b.AddOutputLine("Settings: noise suppression " + enabledLabel(enabled)) + b.UpdateGeneralStatus("Noise suppression: "+enabledLabel(enabled), false) + }}, + adminItem{label: fmt.Sprintf("Microphone level: %.0f percent", micVolume*100)}, + adminItem{label: "Increase microphone level by 10 percent", applicationAction: true, action: func() { + volume := b.adjustMicVolume(0.1) + b.AddOutputLine(fmt.Sprintf("Settings: microphone level %.0f percent", volume*100)) + b.UpdateGeneralStatus(fmt.Sprintf("Microphone level: %.0f percent", volume*100), false) + }}, + adminItem{label: "Decrease microphone level by 10 percent", applicationAction: true, action: func() { + volume := b.adjustMicVolume(-0.1) + b.AddOutputLine(fmt.Sprintf("Settings: microphone level %.0f percent", volume*100)) + b.UpdateGeneralStatus(fmt.Sprintf("Microphone level: %.0f percent", volume*100), false) + }}, + adminItem{label: "Reset microphone level to 100 percent", applicationAction: true, action: func() { + volume := b.setMicVolume(1) + b.AddOutputLine(fmt.Sprintf("Settings: microphone level %.0f percent", volume*100)) + b.UpdateGeneralStatus(fmt.Sprintf("Microphone level: %.0f percent", volume*100), false) + }}, + } +} + +func enabledLabel(enabled bool) string { + if enabled { + return "on" + } + return "off" +} + +func (b *Barnard) keyboardHelpItems() []uiterm.TreeItem { + hotkeys := b.Hotkeys + return []uiterm.TreeItem{ + adminItem{label: "Global shortcuts", children: []uiterm.TreeItem{ + adminItem{label: hotkeyLabel(hotkeys.HelpMenu) + ": open keyboard help"}, + adminItem{label: hotkeyLabel(hotkeys.AdminMenu) + ": open actions and settings"}, + adminItem{label: hotkeyLabel(hotkeys.Talk) + ": toggle transmission"}, + adminItem{label: hotkeyLabel(hotkeys.SelfMuteToggle) + ": toggle self-mute"}, + adminItem{label: hotkeyLabel(hotkeys.NoiseSuppressionToggle) + ": toggle noise suppression"}, + adminItem{label: hotkeyLabel(hotkeys.AGCToggle) + ": toggle automatic gain control"}, + adminItem{label: hotkeyLabel(hotkeys.RecordToggle) + ": toggle recording"}, + adminItem{label: hotkeyLabel(hotkeys.ClearOutput) + ": clear the chat log"}, + adminItem{label: hotkeyLabel(hotkeys.ToggleTimestamps) + ": toggle timestamps"}, + adminItem{label: hotkeyLabel(hotkeys.SwitchViews) + ": switch between message area and user list"}, + adminItem{label: hotkeyLabel(hotkeys.Exit) + ": quit"}, + }}, + adminItem{label: "Message area", children: []uiterm.TreeItem{ + adminItem{label: "Left and Right arrows: move through text"}, + adminItem{label: "Up and Down arrows: previous or next submitted message"}, + adminItem{label: "Home and End: beginning or end of text"}, + }}, + adminItem{label: "User and channel list", children: []uiterm.TreeItem{ + adminItem{label: hotkeyLabel(hotkeys.MuteToggle) + ": mute or unmute focused item"}, + adminItem{label: hotkeyLabel(hotkeys.VolumeDown) + " and " + hotkeyLabel(hotkeys.VolumeUp) + ": decrease or increase volume"}, + adminItem{label: hotkeyLabel(hotkeys.VolumeReset) + ": restore default audio settings"}, + adminItem{label: "Enter: select user for private message or enter channel"}, + }}, + adminItem{label: "Chat log", children: []uiterm.TreeItem{ + adminItem{label: hotkeyLabel(hotkeys.ScrollUp) + " and " + hotkeyLabel(hotkeys.ScrollDown) + ": scroll"}, + adminItem{label: hotkeyLabel(hotkeys.ScrollToTop) + " and " + hotkeyLabel(hotkeys.ScrollToBottom) + ": jump to oldest or newest"}, + }}, + adminItem{label: "Press Escape to close this menu"}, + } +} + +func hotkeyLabel(key *uiterm.Key) string { + if key == nil { + return "Unbound" + } + labels := map[uiterm.Key]string{ + uiterm.KeyF1: "F1", uiterm.KeyF2: "F2", uiterm.KeyF3: "F3", uiterm.KeyF4: "F4", + uiterm.KeyF5: "F5", uiterm.KeyF6: "F6", uiterm.KeyF7: "F7", uiterm.KeyF8: "F8", + uiterm.KeyF9: "F9", uiterm.KeyF10: "F10", uiterm.KeyF11: "F11", uiterm.KeyF12: "F12", + uiterm.KeyCtrlL: "Control+L", uiterm.KeyCtrlQ: "Control+Q", uiterm.KeyCtrlR: "Control+R", uiterm.KeyCtrlT: "Control+T", + uiterm.KeyAltM: "Alt+M", uiterm.KeyTab: "Tab", uiterm.KeyHome: "Home", uiterm.KeyEnd: "End", + uiterm.KeyPgup: "Page Up", uiterm.KeyPgdn: "Page Down", uiterm.KeyArrowUp: "Up Arrow", uiterm.KeyArrowDown: "Down Arrow", + uiterm.KeyArrowLeft: "Left Arrow", uiterm.KeyArrowRight: "Right Arrow", + uiterm.KeyBackspace: "Backspace", uiterm.KeyBackspace2: "Backspace", uiterm.KeyM: "M", + } + if label, ok := labels[*key]; ok { + return label + } + return strings.ReplaceAll(key.String(), "_", " ") +} + func (b *Barnard) AdminItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, key uiterm.Key) { if isAdminEscapeKey(key) { b.OnAdminEscape(ui, key) @@ -169,13 +299,21 @@ func (b *Barnard) AdminItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiter if !ok || admin.action == nil { return } - if !b.withValidAdminTargets(admin.action) { + if admin.applicationAction { + admin.action() + } else if !b.withValidAdminTargets(admin.action) { b.AddOutputLine("Admin: action target is no longer available") } - b.UiAdmin.Rebuild() + b.UiAdmin.RebuildPreservingActiveItem(sameAdminMenuItem) b.Ui.Refresh() } +func sameAdminMenuItem(previous, current uiterm.TreeItem) bool { + previousItem, previousOK := previous.(adminItem) + currentItem, currentOK := current.(adminItem) + return previousOK && currentOK && previousItem.label == currentItem.label +} + func (b *Barnard) AdminItemCharacter(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, ch rune) { if isAdminEscapeCharacter(ch) { b.OnAdminEscape(ui, uiterm.KeyEsc) diff --git a/barnard.go b/barnard.go index 448f139..161e259 100644 --- a/barnard.go +++ b/barnard.go @@ -52,6 +52,8 @@ type Barnard struct { adminTargetUser *gumble.User adminTargetChan *gumble.Channel adminReturnItem uiterm.TreeItem + adminReturnView string + keyboardHelpOpen bool statusText string statusNotice bool diff --git a/config/hotkey_config.go b/config/hotkey_config.go index b1a6317..d1114bb 100644 --- a/config/hotkey_config.go +++ b/config/hotkey_config.go @@ -20,6 +20,7 @@ type Hotkeys struct { ScrollToTop *uiterm.Key ScrollToBottom *uiterm.Key AdminMenu *uiterm.Key + HelpMenu *uiterm.Key NoiseSuppressionToggle *uiterm.Key AGCToggle *uiterm.Key SelfMuteToggle *uiterm.Key diff --git a/config/user_config.go b/config/user_config.go index fe2a656..0b10e48 100644 --- a/config/user_config.go +++ b/config/user_config.go @@ -93,26 +93,7 @@ func key(k uiterm.Key) *uiterm.Key { func (c *Config) LoadConfig() { var jc exportableConfig jc = exportableConfig{} - jc.Hotkeys = &Hotkeys{ - Talk: key(uiterm.KeyCtrlT), - VolumeDown: key(uiterm.KeyArrowLeft), - VolumeUp: key(uiterm.KeyArrowRight), - VolumeReset: key(uiterm.KeyBackspace), - MuteToggle: key(uiterm.KeyM), - RecordToggle: key(uiterm.KeyCtrlR), - Exit: key(uiterm.KeyCtrlQ), - ToggleTimestamps: key(uiterm.KeyF3), - SwitchViews: key(uiterm.KeyTab), - ClearOutput: key(uiterm.KeyCtrlL), - ScrollUp: key(uiterm.KeyPgup), - ScrollDown: key(uiterm.KeyPgdn), - ScrollToTop: key(uiterm.KeyHome), - ScrollToBottom: key(uiterm.KeyEnd), - AdminMenu: key(uiterm.KeyF10), - NoiseSuppressionToggle: key(uiterm.KeyF9), - AGCToggle: key(uiterm.KeyF12), - SelfMuteToggle: key(uiterm.KeyAltM), - } + jc.Hotkeys = &Hotkeys{} if fileExists(c.fn) { var data []byte data = readFile(c.fn) @@ -197,6 +178,7 @@ func (c *Config) ensureHotkeys() { ScrollToTop: key(uiterm.KeyHome), ScrollToBottom: key(uiterm.KeyEnd), AdminMenu: key(uiterm.KeyF10), + HelpMenu: key(uiterm.KeyF1), NoiseSuppressionToggle: key(uiterm.KeyF9), AGCToggle: key(uiterm.KeyF12), SelfMuteToggle: key(uiterm.KeyAltM), @@ -247,6 +229,11 @@ func (c *Config) ensureHotkeys() { if hotkeys.AdminMenu == nil { hotkeys.AdminMenu = defaults.AdminMenu } + if hotkeys.HelpMenu == nil { + if !hotkeyAlreadyUsed(hotkeys, *defaults.HelpMenu) { + hotkeys.HelpMenu = defaults.HelpMenu + } + } if hotkeys.NoiseSuppressionToggle == nil { hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle } @@ -258,6 +245,22 @@ func (c *Config) ensureHotkeys() { } } +func hotkeyAlreadyUsed(hotkeys *Hotkeys, wanted uiterm.Key) bool { + bindings := []*uiterm.Key{ + hotkeys.Talk, hotkeys.VolumeDown, hotkeys.VolumeUp, hotkeys.VolumeReset, + hotkeys.MuteToggle, hotkeys.RecordToggle, hotkeys.Exit, hotkeys.ToggleTimestamps, + hotkeys.SwitchViews, hotkeys.ClearOutput, hotkeys.ScrollUp, hotkeys.ScrollDown, + hotkeys.ScrollToTop, hotkeys.ScrollToBottom, hotkeys.AdminMenu, + hotkeys.NoiseSuppressionToggle, hotkeys.AGCToggle, hotkeys.SelfMuteToggle, + } + for _, binding := range bindings { + if binding != nil && *binding == wanted { + return true + } + } + return false +} + func (c *Config) findServer(address string) *server { if c.config.Servers == nil { c.config.Servers = make([]*server, 0) diff --git a/config/user_config_test.go b/config/user_config_test.go index cc97978..106402e 100644 --- a/config/user_config_test.go +++ b/config/user_config_test.go @@ -55,6 +55,9 @@ func TestConfigBackfillsRecordingDefaults(t *testing.T) { if got := *cfg.GetHotkeys().AdminMenu; got != uiterm.KeyF10 { t.Fatalf("expected admin menu f10, got %s", got) } + if cfg.GetHotkeys().HelpMenu != nil { + t.Fatalf("help binding collided with the configured F1 talk key: %v", *cfg.GetHotkeys().HelpMenu) + } for name, got := range map[string]*uiterm.Key{ "clear output": cfg.GetHotkeys().ClearOutput, "scroll to top": cfg.GetHotkeys().ScrollToTop, @@ -94,6 +97,7 @@ func TestNewHotkeyDefaults(t *testing.T) { {name: "tree mute", got: hotkeys.MuteToggle, want: uiterm.KeyM}, {name: "self mute", got: hotkeys.SelfMuteToggle, want: uiterm.KeyAltM}, {name: "actions menu", got: hotkeys.AdminMenu, want: uiterm.KeyF10}, + {name: "keyboard help", got: hotkeys.HelpMenu, want: uiterm.KeyF1}, {name: "exit", got: hotkeys.Exit, want: uiterm.KeyCtrlQ}, } for _, tt := range tests { @@ -117,6 +121,9 @@ func TestExplicitLegacyHotkeysRemainConfigured(t *testing.T) { *hotkeys.Exit != uiterm.KeyF10 { t.Fatalf("explicit legacy hotkeys were replaced: %+v", hotkeys) } + if hotkeys.HelpMenu != nil { + t.Fatalf("F1 help collided with explicit legacy F1 transmit binding: %v", *hotkeys.HelpMenu) + } } func TestResetUserAudioRestoresAndPersistsDefaults(t *testing.T) { diff --git a/ui.go b/ui.go index 8566a58..3153bcb 100644 --- a/ui.go +++ b/ui.go @@ -128,11 +128,7 @@ func (b *Barnard) OnTimestampToggle(ui *uiterm.Ui, key uiterm.Key) { } func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) { - 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) + enabled := b.toggleNoiseSuppression() if enabled { b.UpdateGeneralStatus("Noise suppression: ON", false) @@ -141,6 +137,15 @@ func (b *Barnard) OnNoiseSuppressionToggle(ui *uiterm.Ui, key uiterm.Key) { } } +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() @@ -466,26 +471,35 @@ func (b *Barnard) OnMicVolumeDown(ui *uiterm.Ui, key uiterm.Key) { if b.ToneTest { return } - b.withStream(func(stream *gumbleopenal.Stream) { - stream.SetMicVolume(-0.1, true) - b.UserConfig.SetMicVolume(stream.GetMicVolume()) - if err := b.UserConfig.SaveConfig(); err != nil { - b.AddOutputLine("Microphone: could not save volume: " + err.Error()) - } - }) + 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(0.1, true) - b.UserConfig.SetMicVolume(stream.GetMicVolume()) - if err := b.UserConfig.SaveConfig(); err != nil { - b.AddOutputLine("Microphone: could not save volume: " + err.Error()) - } + 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) { @@ -527,13 +541,23 @@ func (b *Barnard) OnScrollOutputDown(ui *uiterm.Ui, key uiterm.Key) { } 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 { @@ -683,6 +707,7 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) { 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.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)