From 30afff6ab9f502f17aa754f935dcf0e9d592d991 Mon Sep 17 00:00:00 2001 From: David Long Date: Sun, 20 Sep 2026 22:41:07 -0700 Subject: [PATCH] fix(cli): keep the explore cursor on screen when no scroll offset fits ensureCursorVisible charges the "more items above" indicator against the content height, so with a one-row content area (height 8) no offset ever fits and the search loop leaves the cursor off-screen. The same happens when an unfolded item is taller than the content area. When nothing fits, scroll so the cursor row is at the top: the view draws the indicator above it and the cursor stays visible. ctrl+d on an empty operation list clamped the cursor to -1; guard it like G does. The half-page test on a tiny terminal now asserts the cursor is on screen instead of skipping that check. Fixes #255 --- cmd/openapi/internal/explore/tui/model.go | 11 ++- .../internal/explore/tui/model_keys_test.go | 69 ++++++++++++++----- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/cmd/openapi/internal/explore/tui/model.go b/cmd/openapi/internal/explore/tui/model.go index 082089b..7d3c8a0 100644 --- a/cmd/openapi/internal/explore/tui/model.go +++ b/cmd/openapi/internal/explore/tui/model.go @@ -197,7 +197,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "ctrl+d": - if !m.showHelp { + if !m.showHelp && len(m.operations) > 0 { maxItems := len(m.operations) - 1 newCursorPos := m.cursor + scrollHalfScreenLines @@ -388,6 +388,7 @@ func (m *Model) ensureCursorVisible() { // If the cursor item extends beyond available content height, scroll down if linesUsed > contentHeight { // Find the minimum scroll offset that keeps cursor visible + found := false for newScrollOffset := m.scrollOffset + 1; newScrollOffset <= m.cursor; newScrollOffset++ { testLinesUsed := 0 @@ -403,9 +404,17 @@ func (m *Model) ensureCursorVisible() { if testLinesUsed <= contentHeight { m.scrollOffset = newScrollOffset + found = true break } } + + // Nothing fits when the indicator and the cursor row compete for a single + // line, or the cursor item is taller than the content area. Show the cursor + // row at the top rather than leaving it off-screen. + if !found { + m.scrollOffset = m.cursor + } } // Ensure scroll offset doesn't go negative diff --git a/cmd/openapi/internal/explore/tui/model_keys_test.go b/cmd/openapi/internal/explore/tui/model_keys_test.go index 0cad856..8f1c59b 100644 --- a/cmd/openapi/internal/explore/tui/model_keys_test.go +++ b/cmd/openapi/internal/explore/tui/model_keys_test.go @@ -404,12 +404,11 @@ func TestUpdate_HalfPageKeys(t *testing.T) { ) tests := []struct { - name string - height int - cursor int - key tea.KeyPressMsg - wantCursor int - viewportUnfittable bool + name string + height int + cursor int + key tea.KeyPressMsg + wantCursor int }{ {name: "ctrl+d moves down a half screen", height: height, cursor: 0, key: ctrlKey('d'), wantCursor: scrollHalfScreenLines}, {name: "ctrl+d clamps at the last operation", height: height, cursor: 15, key: ctrlKey('d'), wantCursor: lastRow}, @@ -417,11 +416,9 @@ func TestUpdate_HalfPageKeys(t *testing.T) { {name: "ctrl+u moves up half the content height", height: height, cursor: 25, key: ctrlKey('u'), wantCursor: 25 - halfContent}, {name: "ctrl+u clamps at the first operation", height: height, cursor: 5, key: ctrlKey('u'), wantCursor: 0}, {name: "ctrl+u at the first operation stays put", height: height, cursor: 0, key: ctrlKey('u'), wantCursor: 0}, - // At height 8 the content area is a single row. ensureCursorVisible cannot - // fit the "more items above" indicator plus the cursor row into it, so the - // cursor stays off-screen; that predates the v2 migration, so this case - // checks only the movement and skips the viewport assertion. - {name: "ctrl+u still moves one row on a tiny terminal", height: 8, cursor: 5, key: ctrlKey('u'), wantCursor: 4, viewportUnfittable: true}, + // At height 8 the content area is a single row. + {name: "ctrl+u still moves one row on a tiny terminal", height: 8, cursor: 5, key: ctrlKey('u'), wantCursor: 4}, + {name: "ctrl+d still moves down on a tiny terminal", height: 8, cursor: 0, key: ctrlKey('d'), wantCursor: scrollHalfScreenLines}, } for _, tt := range tests { @@ -435,15 +432,55 @@ func TestUpdate_HalfPageKeys(t *testing.T) { m, cmd := press(t, m, tt.key) assert.Nil(t, cmd) assert.Equal(t, tt.wantCursor, m.cursor) - if tt.viewportUnfittable { - assert.GreaterOrEqual(t, m.scrollOffset, 0) - } else { - assertCursorOnScreen(t, m) - } + assertCursorOnScreen(t, m) }) } } +func TestUpdate_HalfPageDownOnEmptyList(t *testing.T) { + t.Parallel() + + m := resize(t, NewModel(nil, "Test API", "1.0.0"), 28) + + m, cmd := press(t, m, ctrlKey('d')) + assert.Nil(t, cmd) + assert.Equal(t, 0, m.cursor) + assert.Equal(t, 0, m.scrollOffset) +} + +func TestEnsureCursorVisible_SingleRowContentArea(t *testing.T) { + t.Parallel() + + // height 8 leaves one content row, so the "more items above" indicator and + // the cursor row cannot both fit; the cursor row must win. + m := resize(t, NewModel(manyOperations(), "Test API", "1.0.0"), 8) + assert.Equal(t, 1, m.calculateContentHeight()) + + for _, cursor := range []int{1, 5, manyOperationCount - 1} { + m.cursor = cursor + m.ensureCursorVisible() + assert.Equal(t, cursor, m.scrollOffset, "cursor %d", cursor) + assertCursorOnScreen(t, m) + } +} + +func TestEnsureCursorVisible_UnfoldedItemTallerThanContentArea(t *testing.T) { + t.Parallel() + + // height 10 leaves two content rows; an unfolded item spans more than that, + // so the cursor row must be placed at the top rather than left off-screen. + m := resize(t, NewModel(manyOperations(), "Test API", "1.0.0"), 10) + assert.Equal(t, 2, m.calculateContentHeight()) + + m.cursor = 5 + m.operations[5].Folded = false + assert.Greater(t, m.getItemHeight(5), m.calculateContentHeight()) + + m.ensureCursorVisible() + assert.Equal(t, 5, m.scrollOffset) + assertCursorOnScreen(t, m) +} + func TestUpdate_SelectAllKeys(t *testing.T) { t.Parallel()