| 🛟 Updated. 28d5985 k33g 16h ago | 1 | package ui |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "github.com/gdamore/tcell/v2" |
| 8 | ) |
| 9 | |
| 10 | // testDialog builds a dialog with a label, a field and two buttons, laid out |
| 11 | // as the file dialogs actually are. |
| 12 | func testDialog() (*Dialog, *InputLine, *Button, *Button) { |
| 13 | d := NewDialog("Open a File") |
| 14 | d.SetBounds(Rect{X: 10, Y: 5, W: 40, H: 8}) |
| 15 | |
| 16 | label := NewLabel("Name:") |
| 17 | label.SetBounds(d.Layout(2, 2, 10, 1)) |
| 18 | field := NewInputLine("main.go") |
| 19 | field.SetBounds(d.Layout(2, 3, 34, 1)) |
| 20 | ok := NewButton("~O~K", func() { d.Close(ResultOK) }) |
| 21 | ok.Default = true |
| 22 | ok.SetBounds(d.Layout(10, 6, ButtonWidth("~O~K"), 1)) |
| 23 | cancel := NewButton("~C~ancel", func() { d.Close(ResultCancel) }) |
| 24 | cancel.SetBounds(d.Layout(22, 6, ButtonWidth("~C~ancel"), 1)) |
| 25 | |
| 26 | d.Add(label) |
| 27 | d.Add(field) |
| 28 | d.Add(ok) |
| 29 | d.Add(cancel) |
| 30 | return d, field, ok, cancel |
| 31 | } |
| 32 | |
| 33 | func TestADialogStartsOpenWithTheFirstFocusableControlFocused(t *testing.T) { |
| 34 | d, field, _, _ := testDialog() |
| 35 | |
| 36 | if d.Done() { |
| 37 | t.Error("Done() = true on a new dialog") |
| 38 | } |
| 39 | if d.Result() != ResultNone { |
| 40 | t.Errorf("Result() = %v, want ResultNone", d.Result()) |
| 41 | } |
| 42 | if d.Focused() != Focusable(field) { |
| 43 | t.Error("the focus is not on the field; the label should have been skipped") |
| 44 | } |
| 45 | if !field.Focused() { |
| 46 | t.Error("the focused control was not told it has the focus") |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | func TestTabWalksTheFocusRingSkippingLabels(t *testing.T) { |
| 51 | d, field, ok, cancel := testDialog() |
| 52 | |
| 53 | d.HandleKey(keyEvent(tcell.KeyTab, 0, tcell.ModNone)) |
| 54 | if d.Focused() != Focusable(ok) { |
| 55 | t.Error("Tab did not move the focus to the OK button") |
| 56 | } |
| 57 | |
| 58 | d.HandleKey(keyEvent(tcell.KeyTab, 0, tcell.ModNone)) |
| 59 | if d.Focused() != Focusable(cancel) { |
| 60 | t.Error("Tab did not move on to Cancel") |
| 61 | } |
| 62 | |
| 63 | d.HandleKey(keyEvent(tcell.KeyTab, 0, tcell.ModNone)) |
| 64 | if d.Focused() != Focusable(field) { |
| 65 | t.Error("Tab did not wrap round to the field, skipping the label") |
| 66 | } |
| 67 | |
| 68 | d.HandleKey(keyEvent(tcell.KeyBacktab, 0, tcell.ModNone)) |
| 69 | if d.Focused() != Focusable(cancel) { |
| 70 | t.Error("Shift-Tab did not walk the ring backwards") |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | func TestEscapeCancelsTheDialog(t *testing.T) { |
| 75 | d, _, _, _ := testDialog() |
| 76 | |
| 77 | d.HandleKey(keyEvent(tcell.KeyEscape, 0, tcell.ModNone)) |
| 78 | |
| 79 | if !d.Done() || d.Result() != ResultCancel { |
| 80 | t.Errorf("Result() = %v, want ResultCancel", d.Result()) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestEnterPressesTheDefaultButtonFromAnywhere(t *testing.T) { |
| 85 | d, _, _, _ := testDialog() |
| 86 | // The focus is on the input line, which does nothing with Enter. |
| 87 | |
| 88 | d.HandleKey(keyEvent(tcell.KeyEnter, 0, tcell.ModNone)) |
| 89 | |
| 90 | if d.Result() != ResultOK { |
| 91 | t.Errorf("Result() = %v, want the default button to have been pressed", d.Result()) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestAltLetterPressesTheMatchingButton(t *testing.T) { |
| 96 | d, _, _, _ := testDialog() |
| 97 | |
| 98 | d.HandleKey(keyEvent(tcell.KeyRune, 'c', tcell.ModAlt)) |
| 99 | |
| 100 | if d.Result() != ResultCancel { |
| 101 | t.Errorf("Result() = %v, want Alt-C to have pressed Cancel", d.Result()) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestTypingReachesTheFocusedField(t *testing.T) { |
| 106 | d, field, _, _ := testDialog() |
| 107 | |
| 108 | d.HandleKey(runeEvent('!')) |
| 109 | |
| 110 | if got := field.Text(); got != "main.go!" { |
| 111 | t.Errorf("the field holds %q, want the character to have reached it", got) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestADialogSwallowsEveryKey(t *testing.T) { |
| 116 | d, _, _, _ := testDialog() |
| 117 | |
| 118 | // A modal dialog must not let anything through to the editor behind it, |
| 119 | // even a key none of its controls wants. |
| 120 | if !d.HandleKey(keyEvent(tcell.KeyF9, 0, tcell.ModNone)) { |
| 121 | t.Error("a key escaped the modal dialog") |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func TestADialogSwallowsClicksThatMissIt(t *testing.T) { |
| 126 | d, _, _, _ := testDialog() |
| 127 | |
| 128 | if !d.HandleMouse(clickEvent(0, 0)) { |
| 129 | t.Error("a click outside the dialog was passed through to what is behind it") |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestClickingAControlFocusesIt(t *testing.T) { |
| 134 | d, _, _, cancel := testDialog() |
| 135 | |
| 136 | bounds := cancel.Bounds() |
| 137 | d.HandleMouse(clickEvent(bounds.X+1, bounds.Y)) |
| 138 | |
| 139 | if d.Result() != ResultCancel { |
| 140 | t.Errorf("Result() = %v, want the clicked button to have been pressed", d.Result()) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestOnKeyIsOfferedTheKeyFirst(t *testing.T) { |
| 145 | d, field, _, _ := testDialog() |
| 146 | seen := 0 |
| 147 | d.OnKey = func(ev *tcell.EventKey) bool { |
| 148 | seen++ |
| 149 | return ev.Key() == tcell.KeyF1 |
| 150 | } |
| 151 | |
| 152 | if !d.HandleKey(keyEvent(tcell.KeyF1, 0, tcell.ModNone)) { |
| 153 | t.Error("OnKey claimed the key but the dialog reported it unhandled") |
| 154 | } |
| 155 | d.HandleKey(runeEvent('z')) |
| 156 | |
| 157 | if seen != 2 { |
| 158 | t.Errorf("OnKey saw %d keys, want 2", seen) |
| 159 | } |
| 160 | if got := field.Text(); got != "main.goz" { |
| 161 | t.Errorf("the field holds %q, want the key OnKey declined to have reached it", got) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | func TestSetFocusRefusesAControlThatCannotTakeIt(t *testing.T) { |
| 166 | d, field, _, _ := testDialog() |
| 167 | |
| 168 | d.SetFocus(0) // the label |
| 169 | if d.Focused() != Focusable(field) { |
| 170 | t.Error("the focus moved onto a label") |
| 171 | } |
| 172 | |
| 173 | d.SetFocus(99) |
| 174 | if d.Focused() != Focusable(field) { |
| 175 | t.Error("the focus moved onto an index out of range") |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | func TestADialogDrawsItsFrameTitleAndControls(t *testing.T) { |
| 180 | screen, root := newTestScreen(t, 60, 16) |
| 181 | d, _, _, _ := testDialog() |
| 182 | |
| 183 | d.Draw(root, testTheme(t)) |
| 184 | screen.Show() |
| 185 | |
| 186 | lines := screenLines(t, screen) |
| 187 | if !strings.Contains(lines[5], "Open a File") { |
| 188 | t.Errorf("row 5 = %q, want the dialog's title", lines[5]) |
| 189 | } |
| 190 | if !strings.Contains(lines[5], "╔") { |
| 191 | t.Errorf("row 5 = %q, want a double frame", lines[5]) |
| 192 | } |
| 193 | if !strings.Contains(lines[7], "Name:") { |
| 194 | t.Errorf("row 7 = %q, want the label", lines[7]) |
| 195 | } |
| 196 | if !strings.Contains(lines[8], "main.go") { |
| 197 | t.Errorf("row 8 = %q, want the field's content", lines[8]) |
| 198 | } |
| 199 | if !strings.Contains(lines[11], "OK") || !strings.Contains(lines[11], "Cancel") { |
| 200 | t.Errorf("row 11 = %q, want both buttons", lines[11]) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestAnEmptyDialogHandlesKeysWithoutPanicking(t *testing.T) { |
| 205 | d := NewDialog("Empty") |
| 206 | d.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 5}) |
| 207 | |
| 208 | d.HandleKey(keyEvent(tcell.KeyTab, 0, tcell.ModNone)) |
| 209 | d.HandleKey(keyEvent(tcell.KeyEnter, 0, tcell.ModNone)) |
| 210 | |
| 211 | if d.Focused() != nil { |
| 212 | t.Error("Focused() returned a control from an empty dialog") |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | func TestListBoxSelectionAndScrolling(t *testing.T) { |
| 217 | items := make([]string, 30) |
| 218 | for i := range items { |
| 219 | items[i] = string(rune('a' + i%26)) |
| 220 | } |
| 221 | l := NewListBox(items) |
| 222 | l.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 5}) |
| 223 | l.SetFocused(true) |
| 224 | |
| 225 | l.HandleKey(keyEvent(tcell.KeyDown, 0, tcell.ModNone)) |
| 226 | if l.Selected() != 1 { |
| 227 | t.Errorf("Selected() = %d, want 1", l.Selected()) |
| 228 | } |
| 229 | |
| 230 | l.HandleKey(keyEvent(tcell.KeyEnd, 0, tcell.ModNone)) |
| 231 | if l.Selected() != 29 { |
| 232 | t.Errorf("Selected() = %d, want the last item", l.Selected()) |
| 233 | } |
| 234 | |
| 235 | l.HandleKey(keyEvent(tcell.KeyPgUp, 0, tcell.ModNone)) |
| 236 | if l.Selected() != 24 { |
| 237 | t.Errorf("Selected() = %d, want a page back", l.Selected()) |
| 238 | } |
| 239 | |
| 240 | l.HandleKey(keyEvent(tcell.KeyHome, 0, tcell.ModNone)) |
| 241 | if l.Selected() != 0 { |
| 242 | t.Errorf("Selected() = %d, want the first item", l.Selected()) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | func TestListBoxClampsTheSelectionToTheList(t *testing.T) { |
| 247 | l := NewListBox([]string{"one", "two"}) |
| 248 | l.SetFocused(true) |
| 249 | |
| 250 | l.Select(99) |
| 251 | if l.Selected() != 1 { |
| 252 | t.Errorf("Selected() = %d, want the last item", l.Selected()) |
| 253 | } |
| 254 | l.Select(-5) |
| 255 | if l.Selected() != 0 { |
| 256 | t.Errorf("Selected() = %d, want the first item", l.Selected()) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | func TestAnEmptyListBoxHasNoSelection(t *testing.T) { |
| 261 | l := NewListBox(nil) |
| 262 | l.SetFocused(true) |
| 263 | |
| 264 | if got := l.Selected(); got != -1 { |
| 265 | t.Errorf("Selected() = %d, want -1", got) |
| 266 | } |
| 267 | if got := l.SelectedItem(); got != "" { |
| 268 | t.Errorf("SelectedItem() = %q, want empty", got) |
| 269 | } |
| 270 | l.HandleKey(keyEvent(tcell.KeyDown, 0, tcell.ModNone)) // must not panic |
| 271 | l.Choose() |
| 272 | } |
| 273 | |
| 274 | func TestListBoxReportsSelectionsAndChoices(t *testing.T) { |
| 275 | var selected, chosen []int |
| 276 | l := NewListBox([]string{"a", "b", "c"}) |
| 277 | l.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 3}) |
| 278 | l.SetFocused(true) |
| 279 | l.OnSelect = func(i int) { selected = append(selected, i) } |
| 280 | l.OnChoose = func(i int) { chosen = append(chosen, i) } |
| 281 | |
| 282 | l.HandleKey(keyEvent(tcell.KeyDown, 0, tcell.ModNone)) |
| 283 | l.HandleKey(keyEvent(tcell.KeyEnter, 0, tcell.ModNone)) |
| 284 | |
| 285 | if len(selected) != 1 || selected[0] != 1 { |
| 286 | t.Errorf("OnSelect saw %v, want [1]", selected) |
| 287 | } |
| 288 | if len(chosen) != 1 || chosen[0] != 1 { |
| 289 | t.Errorf("OnChoose saw %v, want [1]", chosen) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | func TestSetItemsResetsTheView(t *testing.T) { |
| 294 | l := NewListBox([]string{"a", "b", "c"}) |
| 295 | l.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 2}) |
| 296 | l.SetFocused(true) |
| 297 | l.Select(2) |
| 298 | |
| 299 | l.SetItems([]string{"x", "y"}) |
| 300 | |
| 301 | if l.Selected() != 0 { |
| 302 | t.Errorf("Selected() = %d, want the first of the new items", l.Selected()) |
| 303 | } |
| 304 | if l.top != 0 { |
| 305 | t.Errorf("the view is scrolled to %d, want the top", l.top) |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | func TestListBoxMouseSelectsThenChooses(t *testing.T) { |
| 310 | chosen := 0 |
| 311 | l := NewListBox([]string{"a", "b", "c"}) |
| 312 | l.SetBounds(Rect{X: 0, Y: 2, W: 10, H: 3}) |
| 313 | l.SetFocused(true) |
| 314 | l.OnChoose = func(int) { chosen++ } |
| 315 | |
| 316 | l.HandleMouse(clickEvent(1, 3)) // the second line |
| 317 | if l.Selected() != 1 { |
| 318 | t.Fatalf("Selected() = %d, want 1", l.Selected()) |
| 319 | } |
| 320 | if chosen != 0 { |
| 321 | t.Error("a first click on a line opened it; it should only select") |
| 322 | } |
| 323 | |
| 324 | l.HandleMouse(clickEvent(1, 3)) // the same line again |
| 325 | if chosen != 1 { |
| 326 | t.Error("a second click on the selected line did not open it") |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | func TestListBoxWheelScrolls(t *testing.T) { |
| 331 | l := NewListBox([]string{"a", "b", "c"}) |
| 332 | l.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 3}) |
| 333 | |
| 334 | l.HandleMouse(tcell.NewEventMouse(1, 1, tcell.WheelDown, tcell.ModNone)) |
| 335 | if l.Selected() != 1 { |
| 336 | t.Errorf("Selected() = %d after a wheel-down, want 1", l.Selected()) |
| 337 | } |
| 338 | |
| 339 | l.HandleMouse(tcell.NewEventMouse(1, 1, tcell.WheelUp, tcell.ModNone)) |
| 340 | if l.Selected() != 0 { |
| 341 | t.Errorf("Selected() = %d after a wheel-up, want 0", l.Selected()) |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | func TestListBoxDrawsItsItemsAndScrollBar(t *testing.T) { |
| 346 | screen, root := newTestScreen(t, 20, 6) |
| 347 | l := NewListBox([]string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta"}) |
| 348 | l.SetBounds(Rect{X: 0, Y: 0, W: 12, H: 4}) |
| 349 | l.SetFocused(true) |
| 350 | |
| 351 | l.Draw(root, testTheme(t)) |
| 352 | screen.Show() |
| 353 | |
| 354 | lines := screenLines(t, screen) |
| 355 | if !strings.HasPrefix(lines[0], "alpha") { |
| 356 | t.Errorf("row 0 = %q, want the first item", lines[0]) |
| 357 | } |
| 358 | if !strings.Contains(lines[0], "▲") { |
| 359 | t.Errorf("row 0 = %q, want the scroll bar's up arrow at the right", lines[0]) |
| 360 | } |
| 361 | if !strings.Contains(lines[3], "▼") { |
| 362 | t.Errorf("row 3 = %q, want the scroll bar's down arrow", lines[3]) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | func TestListBoxScrollsToKeepTheSelectionVisible(t *testing.T) { |
| 367 | l := NewListBox([]string{"a", "b", "c", "d", "e", "f", "g", "h"}) |
| 368 | l.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 3}) |
| 369 | l.SetFocused(true) |
| 370 | |
| 371 | l.Select(7) |
| 372 | l.scrollToSelection() |
| 373 | |
| 374 | if l.top != 5 { |
| 375 | t.Errorf("the view is scrolled to %d, want 5 so the last item shows", l.top) |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | func TestTheArrowsReachAListBoxBeforeTheyMoveTheFocus(t *testing.T) { |
| 380 | // A dialog that grabbed the arrows for its focus ring would leave every |
| 381 | // list in it — the theme picker, the window list — unusable by keyboard. |
| 382 | d := NewDialog("Theme") |
| 383 | d.SetBounds(Rect{X: 0, Y: 0, W: 40, H: 12}) |
| 384 | |
| 385 | list := NewListBox([]string{"one", "two", "three"}) |
| 386 | list.SetBounds(d.Layout(2, 2, 30, 5)) |
| 387 | ok := NewButton("~O~K", func() { d.Close(ResultOK) }) |
| 388 | ok.SetBounds(d.Layout(2, 9, ButtonWidth("~O~K"), 1)) |
| 389 | d.Add(list) |
| 390 | d.Add(ok) |
| 391 | |
| 392 | d.HandleKey(keyEvent(tcell.KeyDown, 0, tcell.ModNone)) |
| 393 | |
| 394 | if got := list.Selected(); got != 1 { |
| 395 | t.Errorf("Selected() = %d, want the arrow to have walked the list", got) |
| 396 | } |
| 397 | if d.Focused() != Focusable(list) { |
| 398 | t.Error("the arrow moved the focus off the list instead of walking it") |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | func TestTheArrowsStillWalkTheRingWhenTheFocusedControlDeclinesThem(t *testing.T) { |
| 403 | d := NewDialog("Confirm") |
| 404 | d.SetBounds(Rect{X: 0, Y: 0, W: 40, H: 8}) |
| 405 | |
| 406 | yes := NewButton("~Y~es", nil) |
| 407 | yes.SetBounds(d.Layout(2, 5, ButtonWidth("~Y~es"), 1)) |
| 408 | no := NewButton("~N~o", nil) |
| 409 | no.SetBounds(d.Layout(12, 5, ButtonWidth("~N~o"), 1)) |
| 410 | d.Add(yes) |
| 411 | d.Add(no) |
| 412 | |
| 413 | d.HandleKey(keyEvent(tcell.KeyDown, 0, tcell.ModNone)) |
| 414 | |
| 415 | if d.Focused() != Focusable(no) { |
| 416 | t.Error("the arrow did not move the focus between buttons, which have no use for it") |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | func TestMovingADialogTakesItsControlsWithIt(t *testing.T) { |
| 421 | // Controls are placed in screen coordinates when the dialog is built, so |
| 422 | // moving the frame on its own would leave every one of them behind. |
| 423 | d, field, ok, _ := testDialog() |
| 424 | fieldOffset := field.Bounds().X - d.Bounds().X |
| 425 | buttonOffset := ok.Bounds().Y - d.Bounds().Y |
| 426 | |
| 427 | d.MoveTo(30, 12) |
| 428 | |
| 429 | if got := d.Bounds(); got.X != 30 || got.Y != 12 { |
| 430 | t.Fatalf("Bounds() = %+v, want the dialog at (30, 12)", got) |
| 431 | } |
| 432 | if got := field.Bounds().X - d.Bounds().X; got != fieldOffset { |
| 433 | t.Errorf("the field is %d from the frame, want %d", got, fieldOffset) |
| 434 | } |
| 435 | if got := ok.Bounds().Y - d.Bounds().Y; got != buttonOffset { |
| 436 | t.Errorf("the button is %d from the frame, want %d", got, buttonOffset) |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | func TestMovingADialogNowhereChangesNothing(t *testing.T) { |
| 441 | d, field, _, _ := testDialog() |
| 442 | before := field.Bounds() |
| 443 | |
| 444 | d.MoveTo(d.Bounds().X, d.Bounds().Y) |
| 445 | |
| 446 | if got := field.Bounds(); got != before { |
| 447 | t.Errorf("the field moved to %+v from %+v", got, before) |
| 448 | } |
| 449 | } |
| 450 | |
| 451 | func TestCenterInPutsADialogBackInTheMiddle(t *testing.T) { |
| 452 | d, field, _, _ := testDialog() |
| 453 | fieldOffset := field.Bounds().X - d.Bounds().X |
| 454 | screen := Rect{W: 120, H: 40} |
| 455 | |
| 456 | d.CenterIn(screen) |
| 457 | |
| 458 | want := d.Bounds().CenteredIn(screen) |
| 459 | if got := d.Bounds(); got.X != want.X || got.Y != want.Y { |
| 460 | t.Errorf("Bounds() = %+v, want it centred at %+v", got, want) |
| 461 | } |
| 462 | if got := field.Bounds().X - d.Bounds().X; got != fieldOffset { |
| 463 | t.Errorf("the field is %d from the frame, want %d", got, fieldOffset) |
| 464 | } |
| 465 | } |