package ui import ( "strings" "testing" "github.com/gdamore/tcell/v2" ) // testDialog builds a dialog with a label, a field and two buttons, laid out // as the file dialogs actually are. func testDialog() (*Dialog, *InputLine, *Button, *Button) { d := NewDialog("Open a File") d.SetBounds(Rect{X: 10, Y: 5, W: 40, H: 8}) label := NewLabel("Name:") label.SetBounds(d.Layout(2, 2, 10, 1)) field := NewInputLine("main.go") field.SetBounds(d.Layout(2, 3, 34, 1)) ok := NewButton("~O~K", func() { d.Close(ResultOK) }) ok.Default = true ok.SetBounds(d.Layout(10, 6, ButtonWidth("~O~K"), 1)) cancel := NewButton("~C~ancel", func() { d.Close(ResultCancel) }) cancel.SetBounds(d.Layout(22, 6, ButtonWidth("~C~ancel"), 1)) d.Add(label) d.Add(field) d.Add(ok) d.Add(cancel) return d, field, ok, cancel } func TestADialogStartsOpenWithTheFirstFocusableControlFocused(t *testing.T) { d, field, _, _ := testDialog() if d.Done() { t.Error("Done() = true on a new dialog") } if d.Result() != ResultNone { t.Errorf("Result() = %v, want ResultNone", d.Result()) } if d.Focused() != Focusable(field) { t.Error("the focus is not on the field; the label should have been skipped") } if !field.Focused() { t.Error("the focused control was not told it has the focus") } } func TestTabWalksTheFocusRingSkippingLabels(t *testing.T) { d, field, ok, cancel := testDialog() d.HandleKey(keyEvent(tcell.KeyTab, 0, tcell.ModNone)) if d.Focused() != Focusable(ok) { t.Error("Tab did not move the focus to the OK button") } d.HandleKey(keyEvent(tcell.KeyTab, 0, tcell.ModNone)) if d.Focused() != Focusable(cancel) { t.Error("Tab did not move on to Cancel") } d.HandleKey(keyEvent(tcell.KeyTab, 0, tcell.ModNone)) if d.Focused() != Focusable(field) { t.Error("Tab did not wrap round to the field, skipping the label") } d.HandleKey(keyEvent(tcell.KeyBacktab, 0, tcell.ModNone)) if d.Focused() != Focusable(cancel) { t.Error("Shift-Tab did not walk the ring backwards") } } func TestEscapeCancelsTheDialog(t *testing.T) { d, _, _, _ := testDialog() d.HandleKey(keyEvent(tcell.KeyEscape, 0, tcell.ModNone)) if !d.Done() || d.Result() != ResultCancel { t.Errorf("Result() = %v, want ResultCancel", d.Result()) } } func TestEnterPressesTheDefaultButtonFromAnywhere(t *testing.T) { d, _, _, _ := testDialog() // The focus is on the input line, which does nothing with Enter. d.HandleKey(keyEvent(tcell.KeyEnter, 0, tcell.ModNone)) if d.Result() != ResultOK { t.Errorf("Result() = %v, want the default button to have been pressed", d.Result()) } } func TestAltLetterPressesTheMatchingButton(t *testing.T) { d, _, _, _ := testDialog() d.HandleKey(keyEvent(tcell.KeyRune, 'c', tcell.ModAlt)) if d.Result() != ResultCancel { t.Errorf("Result() = %v, want Alt-C to have pressed Cancel", d.Result()) } } func TestTypingReachesTheFocusedField(t *testing.T) { d, field, _, _ := testDialog() d.HandleKey(runeEvent('!')) if got := field.Text(); got != "main.go!" { t.Errorf("the field holds %q, want the character to have reached it", got) } } func TestADialogSwallowsEveryKey(t *testing.T) { d, _, _, _ := testDialog() // A modal dialog must not let anything through to the editor behind it, // even a key none of its controls wants. if !d.HandleKey(keyEvent(tcell.KeyF9, 0, tcell.ModNone)) { t.Error("a key escaped the modal dialog") } } func TestADialogSwallowsClicksThatMissIt(t *testing.T) { d, _, _, _ := testDialog() if !d.HandleMouse(clickEvent(0, 0)) { t.Error("a click outside the dialog was passed through to what is behind it") } } func TestClickingAControlFocusesIt(t *testing.T) { d, _, _, cancel := testDialog() bounds := cancel.Bounds() d.HandleMouse(clickEvent(bounds.X+1, bounds.Y)) if d.Result() != ResultCancel { t.Errorf("Result() = %v, want the clicked button to have been pressed", d.Result()) } } func TestOnKeyIsOfferedTheKeyFirst(t *testing.T) { d, field, _, _ := testDialog() seen := 0 d.OnKey = func(ev *tcell.EventKey) bool { seen++ return ev.Key() == tcell.KeyF1 } if !d.HandleKey(keyEvent(tcell.KeyF1, 0, tcell.ModNone)) { t.Error("OnKey claimed the key but the dialog reported it unhandled") } d.HandleKey(runeEvent('z')) if seen != 2 { t.Errorf("OnKey saw %d keys, want 2", seen) } if got := field.Text(); got != "main.goz" { t.Errorf("the field holds %q, want the key OnKey declined to have reached it", got) } } func TestSetFocusRefusesAControlThatCannotTakeIt(t *testing.T) { d, field, _, _ := testDialog() d.SetFocus(0) // the label if d.Focused() != Focusable(field) { t.Error("the focus moved onto a label") } d.SetFocus(99) if d.Focused() != Focusable(field) { t.Error("the focus moved onto an index out of range") } } func TestADialogDrawsItsFrameTitleAndControls(t *testing.T) { screen, root := newTestScreen(t, 60, 16) d, _, _, _ := testDialog() d.Draw(root, testTheme(t)) screen.Show() lines := screenLines(t, screen) if !strings.Contains(lines[5], "Open a File") { t.Errorf("row 5 = %q, want the dialog's title", lines[5]) } if !strings.Contains(lines[5], "╔") { t.Errorf("row 5 = %q, want a double frame", lines[5]) } if !strings.Contains(lines[7], "Name:") { t.Errorf("row 7 = %q, want the label", lines[7]) } if !strings.Contains(lines[8], "main.go") { t.Errorf("row 8 = %q, want the field's content", lines[8]) } if !strings.Contains(lines[11], "OK") || !strings.Contains(lines[11], "Cancel") { t.Errorf("row 11 = %q, want both buttons", lines[11]) } } func TestAnEmptyDialogHandlesKeysWithoutPanicking(t *testing.T) { d := NewDialog("Empty") d.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 5}) d.HandleKey(keyEvent(tcell.KeyTab, 0, tcell.ModNone)) d.HandleKey(keyEvent(tcell.KeyEnter, 0, tcell.ModNone)) if d.Focused() != nil { t.Error("Focused() returned a control from an empty dialog") } } func TestListBoxSelectionAndScrolling(t *testing.T) { items := make([]string, 30) for i := range items { items[i] = string(rune('a' + i%26)) } l := NewListBox(items) l.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 5}) l.SetFocused(true) l.HandleKey(keyEvent(tcell.KeyDown, 0, tcell.ModNone)) if l.Selected() != 1 { t.Errorf("Selected() = %d, want 1", l.Selected()) } l.HandleKey(keyEvent(tcell.KeyEnd, 0, tcell.ModNone)) if l.Selected() != 29 { t.Errorf("Selected() = %d, want the last item", l.Selected()) } l.HandleKey(keyEvent(tcell.KeyPgUp, 0, tcell.ModNone)) if l.Selected() != 24 { t.Errorf("Selected() = %d, want a page back", l.Selected()) } l.HandleKey(keyEvent(tcell.KeyHome, 0, tcell.ModNone)) if l.Selected() != 0 { t.Errorf("Selected() = %d, want the first item", l.Selected()) } } func TestListBoxClampsTheSelectionToTheList(t *testing.T) { l := NewListBox([]string{"one", "two"}) l.SetFocused(true) l.Select(99) if l.Selected() != 1 { t.Errorf("Selected() = %d, want the last item", l.Selected()) } l.Select(-5) if l.Selected() != 0 { t.Errorf("Selected() = %d, want the first item", l.Selected()) } } func TestAnEmptyListBoxHasNoSelection(t *testing.T) { l := NewListBox(nil) l.SetFocused(true) if got := l.Selected(); got != -1 { t.Errorf("Selected() = %d, want -1", got) } if got := l.SelectedItem(); got != "" { t.Errorf("SelectedItem() = %q, want empty", got) } l.HandleKey(keyEvent(tcell.KeyDown, 0, tcell.ModNone)) // must not panic l.Choose() } func TestListBoxReportsSelectionsAndChoices(t *testing.T) { var selected, chosen []int l := NewListBox([]string{"a", "b", "c"}) l.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 3}) l.SetFocused(true) l.OnSelect = func(i int) { selected = append(selected, i) } l.OnChoose = func(i int) { chosen = append(chosen, i) } l.HandleKey(keyEvent(tcell.KeyDown, 0, tcell.ModNone)) l.HandleKey(keyEvent(tcell.KeyEnter, 0, tcell.ModNone)) if len(selected) != 1 || selected[0] != 1 { t.Errorf("OnSelect saw %v, want [1]", selected) } if len(chosen) != 1 || chosen[0] != 1 { t.Errorf("OnChoose saw %v, want [1]", chosen) } } func TestSetItemsResetsTheView(t *testing.T) { l := NewListBox([]string{"a", "b", "c"}) l.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 2}) l.SetFocused(true) l.Select(2) l.SetItems([]string{"x", "y"}) if l.Selected() != 0 { t.Errorf("Selected() = %d, want the first of the new items", l.Selected()) } if l.top != 0 { t.Errorf("the view is scrolled to %d, want the top", l.top) } } func TestListBoxMouseSelectsThenChooses(t *testing.T) { chosen := 0 l := NewListBox([]string{"a", "b", "c"}) l.SetBounds(Rect{X: 0, Y: 2, W: 10, H: 3}) l.SetFocused(true) l.OnChoose = func(int) { chosen++ } l.HandleMouse(clickEvent(1, 3)) // the second line if l.Selected() != 1 { t.Fatalf("Selected() = %d, want 1", l.Selected()) } if chosen != 0 { t.Error("a first click on a line opened it; it should only select") } l.HandleMouse(clickEvent(1, 3)) // the same line again if chosen != 1 { t.Error("a second click on the selected line did not open it") } } func TestListBoxWheelScrolls(t *testing.T) { l := NewListBox([]string{"a", "b", "c"}) l.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 3}) l.HandleMouse(tcell.NewEventMouse(1, 1, tcell.WheelDown, tcell.ModNone)) if l.Selected() != 1 { t.Errorf("Selected() = %d after a wheel-down, want 1", l.Selected()) } l.HandleMouse(tcell.NewEventMouse(1, 1, tcell.WheelUp, tcell.ModNone)) if l.Selected() != 0 { t.Errorf("Selected() = %d after a wheel-up, want 0", l.Selected()) } } func TestListBoxDrawsItsItemsAndScrollBar(t *testing.T) { screen, root := newTestScreen(t, 20, 6) l := NewListBox([]string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta"}) l.SetBounds(Rect{X: 0, Y: 0, W: 12, H: 4}) l.SetFocused(true) l.Draw(root, testTheme(t)) screen.Show() lines := screenLines(t, screen) if !strings.HasPrefix(lines[0], "alpha") { t.Errorf("row 0 = %q, want the first item", lines[0]) } if !strings.Contains(lines[0], "▲") { t.Errorf("row 0 = %q, want the scroll bar's up arrow at the right", lines[0]) } if !strings.Contains(lines[3], "▼") { t.Errorf("row 3 = %q, want the scroll bar's down arrow", lines[3]) } } func TestListBoxScrollsToKeepTheSelectionVisible(t *testing.T) { l := NewListBox([]string{"a", "b", "c", "d", "e", "f", "g", "h"}) l.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 3}) l.SetFocused(true) l.Select(7) l.scrollToSelection() if l.top != 5 { t.Errorf("the view is scrolled to %d, want 5 so the last item shows", l.top) } } func TestTheArrowsReachAListBoxBeforeTheyMoveTheFocus(t *testing.T) { // A dialog that grabbed the arrows for its focus ring would leave every // list in it — the theme picker, the window list — unusable by keyboard. d := NewDialog("Theme") d.SetBounds(Rect{X: 0, Y: 0, W: 40, H: 12}) list := NewListBox([]string{"one", "two", "three"}) list.SetBounds(d.Layout(2, 2, 30, 5)) ok := NewButton("~O~K", func() { d.Close(ResultOK) }) ok.SetBounds(d.Layout(2, 9, ButtonWidth("~O~K"), 1)) d.Add(list) d.Add(ok) d.HandleKey(keyEvent(tcell.KeyDown, 0, tcell.ModNone)) if got := list.Selected(); got != 1 { t.Errorf("Selected() = %d, want the arrow to have walked the list", got) } if d.Focused() != Focusable(list) { t.Error("the arrow moved the focus off the list instead of walking it") } } func TestTheArrowsStillWalkTheRingWhenTheFocusedControlDeclinesThem(t *testing.T) { d := NewDialog("Confirm") d.SetBounds(Rect{X: 0, Y: 0, W: 40, H: 8}) yes := NewButton("~Y~es", nil) yes.SetBounds(d.Layout(2, 5, ButtonWidth("~Y~es"), 1)) no := NewButton("~N~o", nil) no.SetBounds(d.Layout(12, 5, ButtonWidth("~N~o"), 1)) d.Add(yes) d.Add(no) d.HandleKey(keyEvent(tcell.KeyDown, 0, tcell.ModNone)) if d.Focused() != Focusable(no) { t.Error("the arrow did not move the focus between buttons, which have no use for it") } } func TestMovingADialogTakesItsControlsWithIt(t *testing.T) { // Controls are placed in screen coordinates when the dialog is built, so // moving the frame on its own would leave every one of them behind. d, field, ok, _ := testDialog() fieldOffset := field.Bounds().X - d.Bounds().X buttonOffset := ok.Bounds().Y - d.Bounds().Y d.MoveTo(30, 12) if got := d.Bounds(); got.X != 30 || got.Y != 12 { t.Fatalf("Bounds() = %+v, want the dialog at (30, 12)", got) } if got := field.Bounds().X - d.Bounds().X; got != fieldOffset { t.Errorf("the field is %d from the frame, want %d", got, fieldOffset) } if got := ok.Bounds().Y - d.Bounds().Y; got != buttonOffset { t.Errorf("the button is %d from the frame, want %d", got, buttonOffset) } } func TestMovingADialogNowhereChangesNothing(t *testing.T) { d, field, _, _ := testDialog() before := field.Bounds() d.MoveTo(d.Bounds().X, d.Bounds().Y) if got := field.Bounds(); got != before { t.Errorf("the field moved to %+v from %+v", got, before) } } func TestCenterInPutsADialogBackInTheMiddle(t *testing.T) { d, field, _, _ := testDialog() fieldOffset := field.Bounds().X - d.Bounds().X screen := Rect{W: 120, H: 40} d.CenterIn(screen) want := d.Bounds().CenteredIn(screen) if got := d.Bounds(); got.X != want.X || got.Y != want.Y { t.Errorf("Bounds() = %+v, want it centred at %+v", got, want) } if got := field.Bounds().X - d.Bounds().X; got != fieldOffset { t.Errorf("the field is %d from the frame, want %d", got, fieldOffset) } }