package ui import ( "strings" "testing" "github.com/gdamore/tcell/v2" "codeberg.org/turbo-editors/turbo-core/theme" ) // stubContent is a widget that records what it was given, so a container's // routing can be checked without a real editor behind it. type stubContent struct { Box keys int clicks int drawnAt Rect consume bool } func (s *stubContent) Draw(p *Painter, _ *theme.Theme) { s.drawnAt = p.Size() p.Fill(p.Size(), '·', tcell.StyleDefault) } func (s *stubContent) HandleKey(*tcell.EventKey) bool { s.keys++ return s.consume } func (s *stubContent) HandleMouse(*tcell.EventMouse) bool { s.clicks++ return s.consume } func TestSplitHotKey(t *testing.T) { tests := []struct { label string wantText string wantKey rune wantIndex int }{ {"~F~ile", "File", 'f', 0}, {"E~x~it", "Exit", 'x', 1}, {"Save ~A~s…", "Save As…", 'a', 5}, {"No hot key", "No hot key", 0, -1}, {"~", "", 0, -1}, } for _, tc := range tests { t.Run(tc.label, func(t *testing.T) { text, key, index := SplitHotKey(tc.label) if text != tc.wantText || key != tc.wantKey || index != tc.wantIndex { t.Errorf("SplitHotKey(%q) = (%q, %q, %d), want (%q, %q, %d)", tc.label, text, key, index, tc.wantText, tc.wantKey, tc.wantIndex) } }) } } func TestLabelWidthIgnoresTheMarkers(t *testing.T) { if got := LabelWidth("~F~ile"); got != 4 { t.Errorf("LabelWidth(\"~F~ile\") = %d, want 4", got) } } func TestMatchesHotKeyIgnoresCase(t *testing.T) { if !MatchesHotKey("~F~ile", 'F') || !MatchesHotKey("~F~ile", 'f') { t.Error("a hot key must match in either case") } if MatchesHotKey("~F~ile", 'x') || MatchesHotKey("File", 'f') { t.Error("a hot key matched something it should not") } } func TestDrawFrameDrawsOnlyTheEdges(t *testing.T) { screen, root := newTestScreen(t, 8, 4) DrawFrame(root, Rect{X: 0, Y: 0, W: 6, H: 3}, FrameDouble, tcell.StyleDefault) screen.Show() lines := screenLines(t, screen) want := []string{"╔════╗ ", "║ ║ ", "╚════╝ ", " "} for i, w := range want { if lines[i] != w { t.Errorf("row %d = %q, want %q", i, lines[i], w) } } } func TestDrawFrameNeedsRoomForItsCorners(t *testing.T) { screen, root := newTestScreen(t, 4, 2) DrawFrame(root, Rect{X: 0, Y: 0, W: 1, H: 5}, FrameSingle, tcell.StyleDefault) screen.Show() for _, line := range screenLines(t, screen) { if strings.TrimSpace(line) != "" { t.Errorf("a frame too narrow to have corners drew something: %q", line) } } } func TestThumbOffsetStaysOnTheTrack(t *testing.T) { tests := []struct { name string position, span, total, track int want int }{ {"at the top", 0, 10, 100, 8, 0}, {"at the bottom", 90, 10, 100, 8, 7}, {"nothing to scroll", 0, 100, 10, 8, 0}, {"past the end", 999, 10, 100, 8, 7}, {"a track too short", 5, 10, 100, 1, 0}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := thumbOffset(tc.position, tc.span, tc.total, tc.track) if got != tc.want { t.Errorf("thumbOffset() = %d, want %d", got, tc.want) } }) } } func TestWindowDrawsAFrameATitleAndItsContent(t *testing.T) { screen, root := newTestScreen(t, 30, 8) content := &stubContent{} w := NewWindow("main.go", content) w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 5}) w.SetActive(true) w.SetNumber(1) w.Draw(root, testTheme(t)) screen.Show() lines := screenLines(t, screen) if !strings.HasPrefix(lines[0], "╔") { t.Errorf("an active window's frame is %q, want a double line", lines[0]) } if !strings.Contains(lines[0], "main.go") { t.Errorf("the title bar is %q, want it to show the title", lines[0]) } if !strings.Contains(lines[0], closeBoxLabel) { t.Errorf("the title bar is %q, want a close box", lines[0]) } if !strings.Contains(lines[0], "1") { t.Errorf("the title bar is %q, want the window number", lines[0]) } if got := content.drawnAt; got != (Rect{W: 18, H: 3}) { t.Errorf("the content was drawn in %+v, want the interior of the frame", got) } } func TestAnInactiveWindowGetsASingleFrame(t *testing.T) { screen, root := newTestScreen(t, 20, 5) w := NewWindow("x", nil) w.SetBounds(Rect{X: 0, Y: 0, W: 10, H: 4}) w.Draw(root, testTheme(t)) screen.Show() if got := screenLines(t, screen)[0]; !strings.HasPrefix(got, "┌") { t.Errorf("an inactive window's frame is %q, want a single line", got) } } func TestWindowPassesInteriorClicksToItsContent(t *testing.T) { content := &stubContent{consume: true} w := NewWindow("t", content) w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 6}) if !w.HandleMouse(clickEvent(5, 3)) { t.Fatal("a click inside the window was not handled") } if content.clicks != 1 { t.Errorf("the content saw %d clicks, want 1", content.clicks) } } func TestAClickOutsideTheWindowIsNotItsBusiness(t *testing.T) { content := &stubContent{} w := NewWindow("t", content) w.SetBounds(Rect{X: 10, Y: 10, W: 5, H: 5}) if w.HandleMouse(clickEvent(0, 0)) { t.Error("the window claimed a click that landed elsewhere") } if content.clicks != 0 { t.Error("the content was given a click from outside the window") } } func TestClickingTheCloseBoxAsksToClose(t *testing.T) { asked := 0 w := NewWindow("t", nil) w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 6}) w.OnClose = func() bool { asked++; return true } w.HandleMouse(clickEvent(3, 0)) // the middle of "[■]" at column 2 if asked != 1 { t.Errorf("OnClose was called %d times, want 1", asked) } } func TestDraggingTheTitleBarMovesTheWindow(t *testing.T) { w := NewWindow("t", nil) w.SetBounds(Rect{X: 2, Y: 2, W: 20, H: 6}) w.HandleMouse(clickEvent(10, 2)) // press on the title bar w.HandleMouse(tcell.NewEventMouse(14, 5, tcell.Button1, tcell.ModNone)) // drag if got := w.Bounds(); got != (Rect{X: 6, Y: 5, W: 20, H: 6}) { t.Errorf("Bounds() = %+v, want the window moved by (4, 3)", got) } if !w.Dragging() { t.Error("Dragging() = false while the button is still down") } w.HandleMouse(releaseEvent(14, 5)) if w.Dragging() { t.Error("Dragging() = true after the button came back up") } } func TestDraggingTheCornerResizesTheWindow(t *testing.T) { w := NewWindow("t", nil) w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 6}) w.HandleMouse(clickEvent(19, 5)) // the bottom-right corner w.HandleMouse(tcell.NewEventMouse(25, 9, tcell.Button1, tcell.ModNone)) if got := w.Bounds(); got != (Rect{X: 0, Y: 0, W: 26, H: 10}) { t.Errorf("Bounds() = %+v, want it grown by (6, 4)", got) } } func TestAWindowCannotBeResizedBelowItsMinimum(t *testing.T) { w := NewWindow("t", nil) w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 6}) w.HandleMouse(clickEvent(19, 5)) w.HandleMouse(tcell.NewEventMouse(0, 0, tcell.Button1, tcell.ModNone)) got := w.Bounds() if got.W != MinWindowWidth || got.H != MinWindowHeight { t.Errorf("Bounds() = %+v, want it clamped to the minimum size", got) } } func TestSettingWindowBoundsLaysTheContentOut(t *testing.T) { content := &stubContent{} w := NewWindow("t", content) w.SetBounds(Rect{X: 3, Y: 4, W: 20, H: 10}) if got := content.Bounds(); got != (Rect{X: 4, Y: 5, W: 18, H: 8}) { t.Errorf("the content is at %+v, want the interior of the frame", got) } } func TestTheCloseBoxIsThreeColumnsWide(t *testing.T) { // The block characters in "[■]" and "[▬]" are three bytes but one column, // so a width measured in bytes would make a box swallow its neighbours. for _, label := range []string{closeBoxLabel, maximizeBoxLabel, restoreBoxLabel} { if got := len([]rune(label)); got != boxWidth { t.Fatalf("%q is %d columns, but boxWidth says %d", label, got, boxWidth) } } closed := 0 w := NewWindow("t", nil) w.SetBounds(Rect{X: 0, Y: 0, W: 20, H: 6}) w.OnClose = func() bool { closed++; return true } for _, x := range []int{2, 3, 4} { w.HandleMouse(clickEvent(x, 0)) } if closed != 3 { t.Errorf("clicking the three columns of the close box asked to close %d times, want 3", closed) } w.HandleMouse(clickEvent(5, 0)) // just past it: this starts a move instead if closed != 3 { t.Error("a click past the close box was taken as a close") } } func TestAWindowTellsItsContentWhetherItIsActive(t *testing.T) { content := &focusableStub{} w := NewWindow("t", content) w.SetActive(true) if !content.Focused() { t.Error("an active window did not give its content the focus") } w.SetActive(false) if content.Focused() { t.Error("an inactive window left the focus with its content") } } func TestAWindowWhoseContentCannotFocusStillWorks(t *testing.T) { w := NewWindow("t", &stubContent{}) // embeds Box, not FocusBox w.SetActive(true) // must not panic if !w.Active() { t.Error("Active() = false after SetActive(true)") } } // focusableStub is a content widget that can hold the focus. type focusableStub struct { FocusBox } func (f *focusableStub) Draw(*Painter, *theme.Theme) {}