| 🛟 Updated. 28d5985 k33g 21h ago | 1 | package ui |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "github.com/gdamore/tcell/v2" |
| 8 | ) |
| 9 | |
| 10 | // submenuBar returns a bar whose second menu holds one plain item and one |
| 11 | // branch, and records what was run. |
| 12 | func submenuBar() (*MenuBar, *[]string) { |
| 13 | var ran []string |
| 14 | record := func(name string) func() { return func() { ran = append(ran, name) } } |
| 15 | |
| 16 | bar := NewMenuBar( |
| 17 | &Menu{Label: "~F~ile", Items: []*MenuItem{ |
| 18 | {Label: "~N~ew", Action: record("new")}, |
| 19 | }}, |
| 20 | &Menu{Label: "~S~nippets", Items: []*MenuItem{ |
| 21 | {Label: "~G~o", Items: []*MenuItem{ |
| 22 | {Label: "~f~unc", Action: record("go/func")}, |
| 23 | {Label: "if ~e~rr", Action: record("go/iferr")}, |
| 24 | }}, |
| 25 | {Label: "~S~hell", Items: []*MenuItem{ |
| 26 | {Label: "~l~oop", Action: record("shell/loop")}, |
| 27 | }}, |
| 28 | {Separator: true}, |
| 29 | {Label: "~C~reate file", Action: record("create")}, |
| 30 | }}, |
| 31 | ) |
| 32 | bar.SetBounds(Rect{W: 80, H: 1}) |
| 33 | return bar, &ran |
| 34 | } |
| 35 | |
| 36 | // key sends a key to the bar. |
| 37 | func key(bar *MenuBar, k tcell.Key) bool { |
| 38 | return bar.HandleKey(tcell.NewEventKey(k, 0, tcell.ModNone)) |
| 39 | } |
| 40 | |
| 41 | // typeRune sends a printable key to the bar. |
| 42 | func typeRune(bar *MenuBar, r rune) bool { |
| 43 | return bar.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) |
| 44 | } |
| 45 | |
| 46 | func TestAnItemWithNoActionButASubmenuCanStillBeChosen(t *testing.T) { |
| 47 | // Without this an item that only opens a branch counts as disabled, and |
| 48 | // the whole submenu is unreachable. |
| 49 | branch := &MenuItem{Label: "Go", Items: []*MenuItem{{Label: "f", Action: func() {}}}} |
| 50 | |
| 51 | if !branch.enabled() { |
| 52 | t.Error("an item with a submenu and no action reports itself disabled") |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestRightOpensTheHighlightedSubmenu(t *testing.T) { |
| 57 | bar, _ := submenuBar() |
| 58 | bar.OpenMenu(1) |
| 59 | |
| 60 | key(bar, tcell.KeyRight) |
| 61 | |
| 62 | if !bar.submenuOpen() { |
| 63 | t.Fatal("right did not open the submenu") |
| 64 | } |
| 65 | if bar.openItem != 0 { |
| 66 | t.Errorf("openItem = %d, want the first branch", bar.openItem) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestRightMovesToTheNextMenuWhenThereIsNoSubmenu(t *testing.T) { |
| 71 | bar, _ := submenuBar() |
| 72 | bar.OpenMenu(0) // File, whose only item is plain |
| 73 | |
| 74 | key(bar, tcell.KeyRight) |
| 75 | |
| 76 | if bar.submenuOpen() { |
| 77 | t.Error("right opened a submenu on an item that has none") |
| 78 | } |
| 79 | if bar.openMenu != 1 { |
| 80 | t.Errorf("openMenu = %d, want the next menu", bar.openMenu) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestEnterOnABranchOpensItRatherThanRunningAnything(t *testing.T) { |
| 85 | bar, ran := submenuBar() |
| 86 | bar.OpenMenu(1) |
| 87 | |
| 88 | key(bar, tcell.KeyEnter) |
| 89 | |
| 90 | if !bar.submenuOpen() { |
| 91 | t.Error("enter on a branch did not open it") |
| 92 | } |
| 93 | if len(*ran) != 0 { |
| 94 | t.Errorf("enter on a branch ran %v", *ran) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestTheArrowsWalkTheSubmenu(t *testing.T) { |
| 99 | bar, _ := submenuBar() |
| 100 | bar.OpenMenu(1) |
| 101 | key(bar, tcell.KeyRight) |
| 102 | |
| 103 | key(bar, tcell.KeyDown) |
| 104 | if bar.subHighlight != 1 { |
| 105 | t.Errorf("subHighlight = %d, want 1", bar.subHighlight) |
| 106 | } |
| 107 | key(bar, tcell.KeyUp) |
| 108 | if bar.subHighlight != 0 { |
| 109 | t.Errorf("subHighlight = %d, want 0", bar.subHighlight) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestEnterInASubmenuRunsTheItemAndClosesEverything(t *testing.T) { |
| 114 | bar, ran := submenuBar() |
| 115 | bar.OpenMenu(1) |
| 116 | key(bar, tcell.KeyRight) |
| 117 | key(bar, tcell.KeyDown) |
| 118 | |
| 119 | key(bar, tcell.KeyEnter) |
| 120 | |
| 121 | if len(*ran) != 1 || (*ran)[0] != "go/iferr" { |
| 122 | t.Errorf("ran %v, want the second submenu item", *ran) |
| 123 | } |
| 124 | if bar.Open() || bar.submenuOpen() { |
| 125 | t.Error("running a submenu item left a menu showing") |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | func TestLeftStepsBackOutOfASubmenu(t *testing.T) { |
| 130 | bar, _ := submenuBar() |
| 131 | bar.OpenMenu(1) |
| 132 | key(bar, tcell.KeyRight) |
| 133 | |
| 134 | key(bar, tcell.KeyLeft) |
| 135 | |
| 136 | if bar.submenuOpen() { |
| 137 | t.Error("left did not close the submenu") |
| 138 | } |
| 139 | if !bar.Open() { |
| 140 | t.Error("left closed the whole menu instead of stepping out") |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestEscapeFromASubmenuClosesTheWholeMenu(t *testing.T) { |
| 145 | // Cancel should mean cancel wherever you are, which is what makes it |
| 146 | // different from left. |
| 147 | bar, _ := submenuBar() |
| 148 | bar.OpenMenu(1) |
| 149 | key(bar, tcell.KeyRight) |
| 150 | |
| 151 | key(bar, tcell.KeyEscape) |
| 152 | |
| 153 | if bar.Open() || bar.submenuOpen() { |
| 154 | t.Error("escape left a menu showing") |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestAHotKeyInsideASubmenuRunsItsItem(t *testing.T) { |
| 159 | bar, ran := submenuBar() |
| 160 | bar.OpenMenu(1) |
| 161 | key(bar, tcell.KeyRight) |
| 162 | |
| 163 | typeRune(bar, 'e') // "if ~e~rr" |
| 164 | |
| 165 | if len(*ran) != 1 || (*ran)[0] != "go/iferr" { |
| 166 | t.Errorf("ran %v, want the item whose hot key was typed", *ran) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestAHotKeyOnABranchOpensItRatherThanRunningIt(t *testing.T) { |
| 171 | bar, ran := submenuBar() |
| 172 | bar.OpenMenu(1) |
| 173 | |
| 174 | typeRune(bar, 's') // "~S~hell" |
| 175 | |
| 176 | if !bar.submenuOpen() || bar.openItem != 1 { |
| 177 | t.Errorf("openItem = %d, submenuOpen = %v; want the Shell branch open", bar.openItem, bar.submenuOpen()) |
| 178 | } |
| 179 | if len(*ran) != 0 { |
| 180 | t.Errorf("a hot key on a branch ran %v", *ran) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestASubmenuSwallowsStrayTyping(t *testing.T) { |
| 185 | bar, ran := submenuBar() |
| 186 | bar.OpenMenu(1) |
| 187 | key(bar, tcell.KeyRight) |
| 188 | |
| 189 | if !typeRune(bar, 'z') { |
| 190 | t.Error("a submenu let a stray key through to whatever is behind it") |
| 191 | } |
| 192 | if len(*ran) != 0 { |
| 193 | t.Errorf("a stray key ran %v", *ran) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | func TestOpeningAnotherMenuClosesTheSubmenu(t *testing.T) { |
| 198 | bar, _ := submenuBar() |
| 199 | bar.OpenMenu(1) |
| 200 | key(bar, tcell.KeyRight) |
| 201 | |
| 202 | bar.OpenMenu(0) |
| 203 | |
| 204 | if bar.submenuOpen() { |
| 205 | t.Error("opening another menu left the submenu showing") |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | func TestASubmenuIsDrawnBesideItsParentWithAMarkerOnTheBranch(t *testing.T) { |
| 210 | screen, root := newTestScreen(t, 80, 12) |
| 211 | bar, _ := submenuBar() |
| 212 | bar.SetBounds(Rect{W: 80, H: 1}) |
| 213 | bar.OpenMenu(1) |
| 214 | key(bar, tcell.KeyRight) |
| 215 | |
| 216 | bar.Draw(root, testTheme(t)) |
| 217 | screen.Show() |
| 218 | lines := screenLines(t, screen) |
| 219 | shown := strings.Join(lines, "\n") |
| 220 | |
| 221 | if !strings.Contains(shown, submenuMarker) { |
| 222 | t.Errorf("no branch marker is drawn:\n%s", shown) |
| 223 | } |
| 224 | for _, want := range []string{"Go", "Shell", "func", "if err"} { |
| 225 | if !strings.Contains(shown, want) { |
| 226 | t.Errorf("%q is not on the screen:\n%s", want, shown) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | // The two panels must not sit on top of each other. |
| 231 | parent, sub := bar.dropdownBounds(), bar.submenuBounds() |
| 232 | if parent.Intersect(sub).W > 0 && parent.Intersect(sub).H > 0 { |
| 233 | t.Errorf("the panels overlap: parent %+v, submenu %+v", parent, sub) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | func TestASubmenuFlipsLeftWhenThereIsNoRoomOnTheRight(t *testing.T) { |
| 238 | // A menu drawn past the edge of the screen is a menu nobody can read. |
| 239 | bar := NewMenuBar( |
| 240 | &Menu{Label: "A", Items: nil}, |
| 241 | &Menu{Label: "B", Items: nil}, |
| 242 | &Menu{Label: "~S~nippets", Items: []*MenuItem{ |
| 243 | {Label: "A branch with a long name", Items: []*MenuItem{ |
| 244 | {Label: "an item with a very long label indeed", Action: func() {}}, |
| 245 | }}, |
| 246 | }}, |
| 247 | ) |
| 248 | bar.SetBounds(Rect{W: 40, H: 1}) |
| 249 | bar.OpenMenu(2) |
| 250 | key(bar, tcell.KeyRight) |
| 251 | |
| 252 | sub := bar.submenuBounds() |
| 253 | if sub.Right() > bar.Bounds().Right() { |
| 254 | t.Errorf("the submenu at %+v runs past the screen %+v", sub, bar.Bounds()) |
| 255 | } |
| 256 | if sub.X < bar.Bounds().X { |
| 257 | t.Errorf("the submenu at %+v starts before the screen", sub) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | func TestMovingThePointerOntoABranchOpensItAndOffItClosesIt(t *testing.T) { |
| 262 | bar, _ := submenuBar() |
| 263 | bar.OpenMenu(1) |
| 264 | bounds := bar.dropdownBounds() |
| 265 | |
| 266 | // Hover the first branch. |
| 267 | bar.HandleMouse(tcell.NewEventMouse(bounds.X+2, bounds.Y+1, tcell.ButtonNone, tcell.ModNone)) |
| 268 | if !bar.submenuOpen() { |
| 269 | t.Fatal("hovering a branch did not open its submenu") |
| 270 | } |
| 271 | |
| 272 | // Hover the plain item further down. |
| 273 | bar.HandleMouse(tcell.NewEventMouse(bounds.X+2, bounds.Y+4, tcell.ButtonNone, tcell.ModNone)) |
| 274 | if bar.submenuOpen() { |
| 275 | t.Error("moving off a branch left its submenu showing") |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | func TestClickingASubmenuItemRunsIt(t *testing.T) { |
| 280 | bar, ran := submenuBar() |
| 281 | bar.OpenMenu(1) |
| 282 | key(bar, tcell.KeyRight) |
| 283 | sub := bar.submenuBounds() |
| 284 | |
| 285 | bar.HandleMouse(tcell.NewEventMouse(sub.X+2, sub.Y+1, tcell.Button1, tcell.ModNone)) |
| 286 | |
| 287 | if len(*ran) != 1 || (*ran)[0] != "go/func" { |
| 288 | t.Errorf("ran %v, want the clicked submenu item", *ran) |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func TestOnOpenRunsBeforeTheMenuDropsDown(t *testing.T) { |
| 293 | // A menu built from a file cannot be decided at start-up; this is the hook |
| 294 | // that fills it in at the only moment it is about to be seen. |
| 295 | filled := 0 |
| 296 | menu := &Menu{Label: "~S~nippets"} |
| 297 | menu.OnOpen = func() { |
| 298 | filled++ |
| 299 | menu.Items = []*MenuItem{{Label: "from the file", Action: func() {}}} |
| 300 | } |
| 301 | |
| 302 | bar := NewMenuBar(menu) |
| 303 | bar.SetBounds(Rect{W: 80, H: 1}) |
| 304 | bar.OpenMenu(0) |
| 305 | |
| 306 | if filled != 1 { |
| 307 | t.Errorf("OnOpen ran %d times, want once", filled) |
| 308 | } |
| 309 | if len(menu.Items) != 1 { |
| 310 | t.Fatalf("the menu holds %d items after OnOpen", len(menu.Items)) |
| 311 | } |
| 312 | if bar.highlight != 0 { |
| 313 | t.Errorf("highlight = %d; the highlight must be computed after OnOpen filled the menu", bar.highlight) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | func TestAnEmptyMenuOpensWithoutPanicking(t *testing.T) { |
| 318 | bar := NewMenuBar(&Menu{Label: "~S~nippets"}) |
| 319 | bar.SetBounds(Rect{W: 80, H: 1}) |
| 320 | |
| 321 | bar.OpenMenu(0) |
| 322 | key(bar, tcell.KeyDown) |
| 323 | key(bar, tcell.KeyRight) |
| 324 | key(bar, tcell.KeyEnter) |
| 325 | } |