diff --git a/admin.go b/admin.go index ae8c1e2..aed63e6 100644 --- a/admin.go +++ b/admin.go @@ -50,19 +50,21 @@ func (b *Barnard) OnAdminEscape(ui *uiterm.Ui, key uiterm.Key) { return } if b.Ui.Active() == uiViewAdmin { - b.Ui.SetActive(uiViewTree) - width, height := termboxSize() - b.OnUiResize(ui, width, height) + b.CloseAdminMenu(ui) b.AddOutputLine("Admin: closed") - ui.Refresh() } } +func (b *Barnard) closeAdminAction() { + b.OnAdminEscape(b.Ui, uiterm.KeyEsc) +} + func (b *Barnard) OpenAdminMenu() { if b.Client == nil || b.Client.Self == nil { b.AddOutputLine("Admin: not connected") return } + b.adminReturnItem = b.UiTree.ActiveItem() b.adminTargetUser = b.selectedUser b.adminTargetChan = b.Client.Self.Channel if b.Ui.Active() == uiViewTree { @@ -98,6 +100,15 @@ func (b *Barnard) OpenAdminMenu() { b.Ui.Refresh() } +func (b *Barnard) CloseAdminMenu(ui *uiterm.Ui) { + b.Ui.SetActive(uiViewTree) + b.UiTree.SetActiveItem(b.adminReturnItem, sameUserChannelTreeItem) + b.adminReturnItem = nil + width, height := termboxSize() + b.OnUiResize(ui, width, height) + ui.Refresh() +} + func termboxSize() (int, int) { width, height := termbox.Size() return width, height @@ -140,11 +151,15 @@ 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: func() { b.OnAdminEscape(b.Ui, uiterm.KeyEsc) }}) + items = append(items, adminItem{label: "Close actions menu", action: b.closeAdminAction}) return items } func (b *Barnard) AdminItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, key uiterm.Key) { + if isAdminEscapeKey(key) { + b.OnAdminEscape(ui, key) + return + } if key != uiterm.KeyEnter { return } @@ -158,6 +173,22 @@ func (b *Barnard) AdminItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiter } func (b *Barnard) AdminItemCharacter(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, ch rune) { + if isAdminEscapeCharacter(ch) { + b.OnAdminEscape(ui, uiterm.KeyEsc) + } +} + +func isAdminEscapeKey(key uiterm.Key) bool { + switch key { + case uiterm.KeyEsc, uiterm.KeyAltEsc, uiterm.KeyAltArrowUp, uiterm.KeyAltArrowDown: + return true + default: + return false + } +} + +func isAdminEscapeCharacter(ch rune) bool { + return ch == rune(uiterm.KeyEsc) } func (b *Barnard) informationItems() []uiterm.TreeItem { diff --git a/admin_test.go b/admin_test.go index f78d200..44682fb 100644 --- a/admin_test.go +++ b/admin_test.go @@ -4,6 +4,7 @@ import ( "testing" "git.stormux.org/storm/barnard/gumble/gumble" + "git.stormux.org/storm/barnard/uiterm" ) func TestParseToggleState(t *testing.T) { @@ -63,3 +64,27 @@ func TestPermissionList(t *testing.T) { t.Fatalf("expected write,ban, got %q", got) } } + +func TestAdminEscapeInputs(t *testing.T) { + if !isAdminEscapeKey(uiterm.KeyEsc) { + t.Fatal("expected escape key to close admin menu") + } + if !isAdminEscapeKey(uiterm.KeyAltEsc) { + t.Fatal("expected alt escape key to close admin menu") + } + if !isAdminEscapeKey(uiterm.KeyAltArrowUp) { + t.Fatal("expected escape-prefixed up arrow to close admin menu") + } + if !isAdminEscapeKey(uiterm.KeyAltArrowDown) { + t.Fatal("expected escape-prefixed down arrow to close admin menu") + } + if isAdminEscapeKey(uiterm.KeyEnter) { + t.Fatal("expected enter key not to close admin menu") + } + if !isAdminEscapeCharacter(rune(uiterm.KeyEsc)) { + t.Fatal("expected escape character to close admin menu") + } + if isAdminEscapeCharacter('x') { + t.Fatal("expected non-escape character not to close admin menu") + } +} diff --git a/barnard.go b/barnard.go index 9f3604a..ad08677 100644 --- a/barnard.go +++ b/barnard.go @@ -43,6 +43,7 @@ type Barnard struct { selectedUser *gumble.User adminTargetUser *gumble.User adminTargetChan *gumble.Channel + adminReturnItem uiterm.TreeItem statusText string statusNotice bool @@ -144,7 +145,7 @@ func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm delete(b.MutedChannels, treeItem.Channel.ID) } - b.UiTree.Rebuild() + b.RebuildUserChannelTreePreservingSelection() b.Ui.Refresh() } if key == *b.Hotkeys.VolumeDown { @@ -169,7 +170,7 @@ func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm treeItem.User.AudioSource.SetGain(treeItem.User.Volume) } } - b.UiTree.Rebuild() + b.RebuildUserChannelTreePreservingSelection() b.Ui.Refresh() } if key == *b.Hotkeys.VolumeDown { diff --git a/client.go b/client.go index d3a4095..2c09bff 100644 --- a/client.go +++ b/client.go @@ -216,7 +216,7 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) { b.AddOutputLine(formatUserStats(e.User)) } b.updateUserChannel(e) - b.UiTree.Rebuild() + b.RebuildUserChannelTreePreservingSelection() b.Ui.Refresh() } @@ -317,7 +317,7 @@ func (b *Barnard) OnChannelChange(e *gumble.ChannelChangeEvent) { b.AddOutputLine(fmt.Sprintf("Channel permissions for %s: %s", e.Channel.Name, permissionList(*permission))) } } - b.UiTree.Rebuild() + b.RebuildUserChannelTreePreservingSelection() b.Ui.Refresh() } diff --git a/ui.go b/ui.go index 4e5acd8..569a054 100644 --- a/ui.go +++ b/ui.go @@ -66,7 +66,7 @@ func (b *Barnard) UpdateInputStatus(status string) { status = status[:17] + "..." + "]" } b.UiInputStatus.Text = status - b.UiTree.Rebuild() + b.RebuildUserChannelTreePreservingSelection() b.Ui.Refresh() } diff --git a/ui_tree.go b/ui_tree.go index 2f32ec5..6e60b3c 100644 --- a/ui_tree.go +++ b/ui_tree.go @@ -1,155 +1,177 @@ package main import ( - "fmt" - "git.stormux.org/storm/barnard/gumble/gumble" - "git.stormux.org/storm/barnard/uiterm" - "sort" + "fmt" + "git.stormux.org/storm/barnard/gumble/gumble" + "git.stormux.org/storm/barnard/uiterm" + "sort" ) func (ti TreeItem) String() string { - if ti.User != nil { - if ti.User.LocallyMuted { - return "[MUTED] " + ti.User.Name - } - // Calculate total volume as percentage - boostPercent := float32(ti.User.Boost-1) * 10 - totalVolume := ti.User.Volume*100 + boostPercent - return fmt.Sprintf("%s [%.0f%%]", ti.User.Name, totalVolume) - } - if ti.Channel != nil { - return "#" + ti.Channel.Name - } - return "" + if ti.User != nil { + if ti.User.LocallyMuted { + return "[MUTED] " + ti.User.Name + } + // Calculate total volume as percentage + boostPercent := float32(ti.User.Boost-1) * 10 + totalVolume := ti.User.Volume*100 + boostPercent + return fmt.Sprintf("%s [%.0f%%]", ti.User.Name, totalVolume) + } + if ti.Channel != nil { + return "#" + ti.Channel.Name + } + return "" } func (ti TreeItem) TreeItemStyle(fg, bg uiterm.Attribute, active bool) (uiterm.Attribute, uiterm.Attribute) { - if ti.Channel != nil { - fg |= uiterm.AttrBold - } - if active { - fg, bg = bg, fg - } - return fg, bg + if ti.Channel != nil { + fg |= uiterm.AttrBold + } + if active { + fg, bg = bg, fg + } + return fg, bg } func (b *Barnard) changeVolume(users []*gumble.User, change float32) { - for _, u := range users { - au := u.AudioSource - if au == nil { - continue - } - var boost uint16 - var cv float32 - var ng float32 - var curboost float32 - curboost = float32((u.Boost - 1)) / 10 - cv = au.GetGain() + curboost - ng = cv + change - boost = uint16(1) - if ng > 1.0 { - perc := uint16((ng * 10)) - 10 - perc += 1 - boost = perc - ng = 1.0 - } - if ng < 0 { - ng = 0.0 - } - u.Boost = boost - u.Volume = ng - if !u.LocallyMuted { - au.SetGain(ng) - } - b.UserConfig.UpdateConfig(u) - } - b.UserConfig.SaveConfig() + for _, u := range users { + au := u.AudioSource + if au == nil { + continue + } + var boost uint16 + var cv float32 + var ng float32 + var curboost float32 + curboost = float32((u.Boost - 1)) / 10 + cv = au.GetGain() + curboost + ng = cv + change + boost = uint16(1) + if ng > 1.0 { + perc := uint16((ng * 10)) - 10 + perc += 1 + boost = perc + ng = 1.0 + } + if ng < 0 { + ng = 0.0 + } + u.Boost = boost + u.Volume = ng + if !u.LocallyMuted { + au.SetGain(ng) + } + b.UserConfig.UpdateConfig(u) + } + b.UserConfig.SaveConfig() } func (b *Barnard) resetVolume(users []*gumble.User) { - for _, u := range users { - au := u.AudioSource - if au == nil { - continue - } - // Reset to original volume (1.0) and boost (1) - u.Boost = uint16(1) - u.Volume = 1.0 - if !u.LocallyMuted { - au.SetGain(1.0) - } - b.UserConfig.UpdateConfig(u) - } - b.UserConfig.SaveConfig() + for _, u := range users { + au := u.AudioSource + if au == nil { + continue + } + // Reset to original volume (1.0) and boost (1) + u.Boost = uint16(1) + u.Volume = 1.0 + if !u.LocallyMuted { + au.SetGain(1.0) + } + b.UserConfig.UpdateConfig(u) + } + b.UserConfig.SaveConfig() } func makeUsersArray(users gumble.Users) []*gumble.User { - t := make([]*gumble.User, 0, len(users)) - for _, u := range users { - t = append(t, u) - } - return t + t := make([]*gumble.User, 0, len(users)) + for _, u := range users { + t = append(t, u) + } + return t } func (b *Barnard) TreeItemBuild(item uiterm.TreeItem) []uiterm.TreeItem { - if b.Client == nil { - return nil - } + if b.Client == nil { + return nil + } - var treeItem TreeItem - if ti, ok := item.(TreeItem); !ok { - root := b.Client.Channels[0] - if root == nil { - return nil - } - return []uiterm.TreeItem{ - TreeItem{ - Channel: root, - }, - } - } else { - treeItem = ti - } + var treeItem TreeItem + if ti, ok := item.(TreeItem); !ok { + root := b.Client.Channels[0] + if root == nil { + return nil + } + return []uiterm.TreeItem{ + TreeItem{ + Channel: root, + }, + } + } else { + treeItem = ti + } - if treeItem.User != nil { - return nil - } + if treeItem.User != nil { + return nil + } - users := []uiterm.TreeItem{} - ul := []*gumble.User{} - for _, user := range treeItem.Channel.Users { - ul = append(ul, user) - var u = ul[len(ul)-1] - _ = u - } - sort.Slice(ul, func(i, j int) bool { - return ul[i].Name < ul[j].Name - }) - for _, user := range ul { - users = append(users, TreeItem{ - User: user, - }) - } + users := []uiterm.TreeItem{} + ul := []*gumble.User{} + for _, user := range treeItem.Channel.Users { + ul = append(ul, user) + var u = ul[len(ul)-1] + _ = u + } + sort.Slice(ul, func(i, j int) bool { + return ul[i].Name < ul[j].Name + }) + for _, user := range ul { + users = append(users, TreeItem{ + User: user, + }) + } - channels := []uiterm.TreeItem{} - cl := []*gumble.Channel{} - for _, subchannel := range treeItem.Channel.Children { - cl = append(cl, subchannel) - } - sort.Slice(cl, func(i, j int) bool { - return cl[i].Name < cl[j].Name - }) - for _, subchannel := range cl { - displayName := subchannel.Name - if b.MutedChannels[subchannel.ID] { - displayName = "[MUTED] #" + displayName - } else { - displayName = "#" + displayName - } - channels = append(channels, TreeItem{ - Channel: subchannel, - }) - } + channels := []uiterm.TreeItem{} + cl := []*gumble.Channel{} + for _, subchannel := range treeItem.Channel.Children { + cl = append(cl, subchannel) + } + sort.Slice(cl, func(i, j int) bool { + return cl[i].Name < cl[j].Name + }) + for _, subchannel := range cl { + displayName := subchannel.Name + if b.MutedChannels[subchannel.ID] { + displayName = "[MUTED] #" + displayName + } else { + displayName = "#" + displayName + } + channels = append(channels, TreeItem{ + Channel: subchannel, + }) + } - return append(users, channels...) + return append(users, channels...) +} + +func (b *Barnard) RebuildUserChannelTreePreservingSelection() { + b.UiTree.RebuildPreservingActiveItem(sameUserChannelTreeItem) +} + +func sameUserChannelTreeItem(previous, current uiterm.TreeItem) bool { + prev, ok := previous.(TreeItem) + if !ok { + return false + } + cur, ok := current.(TreeItem) + if !ok { + return false + } + if prev.User != nil && cur.User != nil { + return prev.User.Session == cur.User.Session + } + if prev.Channel != nil && cur.Channel != nil { + return prev.Channel.ID == cur.Channel.ID + } + return false } diff --git a/uiterm/tree.go b/uiterm/tree.go index 8bb0141..b44ddf3 100644 --- a/uiterm/tree.go +++ b/uiterm/tree.go @@ -59,11 +59,34 @@ func (t *Tree) uiSetBounds(x0, y0, x1, y1 int) { } func (t *Tree) Rebuild() { + t.rebuild(false, nil) +} + +func (t *Tree) RebuildPreservingActiveItem(sameItem func(previous, current TreeItem) bool) { + t.rebuild(true, sameItem) +} + +func (t *Tree) SetActiveItem(target TreeItem, sameItem func(previous, current TreeItem) bool) bool { + if target == nil || sameItem == nil { + return false + } + for line, item := range t.lines { + if sameItem(target, item.Item) { + t.SetActiveLine(line, false) + return true + } + } + return false +} + +func (t *Tree) rebuild(preserveActive bool, sameItem func(previous, current TreeItem) bool) { if t.Generator == nil { t.lines = []renderedTreeItem{} return } + previousItem := t.ActiveItem() + previousLine := t.activeLine lines := []renderedTreeItem{} for _, item := range t.Generator(nil) { children := t.rebuild_rec(item, 0) @@ -72,8 +95,22 @@ func (t *Tree) Rebuild() { } } t.lines = lines - t.SetActiveLine(0, false) - t.uiDraw() + if preserveActive { + t.SetActiveLine(previousLine, false) + if previousItem != nil && sameItem != nil { + for line, item := range t.lines { + if sameItem(previousItem, item.Item) { + t.SetActiveLine(line, false) + break + } + } + } + } else { + t.SetActiveLine(0, false) + } + if t.ui != nil { + t.uiDraw() + } } func (t *Tree) rebuild_rec(parent TreeItem, level int) []renderedTreeItem { @@ -138,11 +175,10 @@ func (t *Tree) uiDraw() { fg := t.Fg bg := t.Bg dx := x - t.x0 - dy := y - t.y0 if reader != nil && level*2 <= dx { if ch, _, err := reader.ReadRune(); err == nil { chr = ch - fg, bg = item.TreeItemStyle(fg, bg, t.active && t.activeLine == dy) + fg, bg = item.TreeItemStyle(fg, bg, t.active && t.activeLine == line) } } termbox.SetCell(x, y, chr, termbox.Attribute(fg), termbox.Attribute(bg)) diff --git a/uiterm/tree_test.go b/uiterm/tree_test.go new file mode 100644 index 0000000..0cbb463 --- /dev/null +++ b/uiterm/tree_test.go @@ -0,0 +1,86 @@ +package uiterm + +import "testing" + +type testTreeItem string + +func (i testTreeItem) String() string { + return string(i) +} + +func (i testTreeItem) TreeItemStyle(fg, bg Attribute, active bool) (Attribute, Attribute) { + return fg, bg +} + +func TestTreeRebuildResetsActiveLine(t *testing.T) { + t.Parallel() + + tree := Tree{ + Generator: func(item TreeItem) []TreeItem { + if item != nil { + return nil + } + return []TreeItem{testTreeItem("one"), testTreeItem("two"), testTreeItem("three")} + }, + } + + tree.Rebuild() + tree.SetActiveLine(2, false) + tree.Rebuild() + + if tree.activeLine != 0 { + t.Fatalf("expected active line to reset to 0, got %d", tree.activeLine) + } +} + +func TestTreeRebuildPreservingActiveItemKeepsMatchingItem(t *testing.T) { + t.Parallel() + + items := []TreeItem{testTreeItem("one"), testTreeItem("two"), testTreeItem("three")} + tree := Tree{ + Generator: func(item TreeItem) []TreeItem { + if item != nil { + return nil + } + return items + }, + } + + tree.Rebuild() + tree.SetActiveLine(1, false) + items = []TreeItem{testTreeItem("three"), testTreeItem("one"), testTreeItem("two")} + tree.RebuildPreservingActiveItem(func(previous, current TreeItem) bool { + return previous.String() == current.String() + }) + + if tree.activeLine != 2 { + t.Fatalf("expected active line to follow matching item to line 2, got %d", tree.activeLine) + } + if got := tree.ActiveItem().String(); got != "two" { + t.Fatalf("expected active item two, got %q", got) + } +} + +func TestTreeSetActiveItemSelectsMatchingItem(t *testing.T) { + t.Parallel() + + tree := Tree{ + Generator: func(item TreeItem) []TreeItem { + if item != nil { + return nil + } + return []TreeItem{testTreeItem("one"), testTreeItem("two"), testTreeItem("three")} + }, + } + + tree.Rebuild() + if ok := tree.SetActiveItem(testTreeItem("three"), func(previous, current TreeItem) bool { + return previous.String() == current.String() + }); !ok { + t.Fatal("expected matching item to be selected") + } + + if tree.activeLine != 2 { + t.Fatalf("expected active line 2, got %d", tree.activeLine) + } +}