diff --git a/internal/ui/project.go b/internal/ui/project.go index f93ac1cb..04c33f55 100644 --- a/internal/ui/project.go +++ b/internal/ui/project.go @@ -59,19 +59,10 @@ var ( paginatorActiveDotStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#0087D7", Dark: "#00FFFF"}) - errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#D70000", Dark: "#FF0000"}). - MarginTop(1) - successStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#00875F", Dark: "#00FF00"}). MarginTop(1) - overlayStyle = lipgloss.NewStyle(). - Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}). - Width(width). - Height(1) - modalStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.AdaptiveColor{Light: "#D70000", Dark: "#FF0000"}). @@ -81,12 +72,6 @@ var ( Width(50). Align(lipgloss.Left) - modalTitleStyle = lipgloss.NewStyle(). - Background(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#1A1A1A"}). - Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}). - Width(46). - Align(lipgloss.Left) - contentStyle = lipgloss.NewStyle(). Border(lipgloss.HiddenBorder()). BorderForeground(lipgloss.AdaptiveColor{Light: "#666666", Dark: "#626262"}). @@ -217,9 +202,10 @@ type projectFormModel struct { showErrorModal bool // New fields for scrolling viewport viewport.Model - scrollOffset int itemHeight int contentHeight int + windowStart int + windowSize int ready bool } @@ -365,7 +351,7 @@ func initialProjectModel(initialForm ProjectForm) projectFormModel { depsError: "", form: initialForm, showErrorModal: false, - itemHeight: 4, + itemHeight: 3, ready: false, } @@ -435,7 +421,7 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update styles that depend on window width titleStyle = titleStyle.Width(m.width) helpStyle = helpStyle.Width(m.width) - // Always reinitialize the viewport on resize to avoid invalid state + // Recompute window size on resize m.initViewport() return m, nil @@ -444,44 +430,23 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.ready { return m, nil } - - switch msg.String() { - case "MouseWheelUp": - m.viewport.LineUp(1) - // Update cursor based on scroll position if needed - m.updateCursorFromScroll() - case "MouseWheelDown": - m.viewport.LineDown(1) - // Update cursor based on scroll position if needed - m.updateCursorFromScroll() - case "MouseLeft": + if msg.String() == "MouseLeft" { m.mouseY = msg.Y - // Convert mouse Y to list index considering scroll position - clickedIndex := (msg.Y - 6 + m.viewport.YOffset) / m.itemHeight + // Approximate top offset of list area; keep prior 6-line offset heuristic + localIndex := (msg.Y - 6) / m.itemHeight + clickedIndex := m.windowStart + localIndex switch m.step { case 0: if clickedIndex >= 0 && clickedIndex < len(m.choices) { m.cursor = clickedIndex - if m.runtime == m.choices[clickedIndex].ID { - m.stepCursors[m.step] = m.cursor - m.runtime = m.choices[m.cursor].ID - m.runtimeName = m.choices[m.cursor].Name - m.step++ - m.cursor = m.stepCursors[m.step] - } + m.ensureCursorVisible() } case 1: if templates, ok := m.templates[m.runtime]; ok { if clickedIndex >= 0 && clickedIndex < len(templates) { m.cursor = clickedIndex - if m.template == templates[clickedIndex].Name { - m.stepCursors[m.step] = m.cursor - m.template = templates[m.cursor].Name - m.step++ - m.cursor = 0 - m.projectName.Focus() - } + m.ensureCursorVisible() } } } @@ -548,6 +513,8 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor = 0 m.stepCursors[m.step] = 0 } + // Ensure selection is visible when entering template step + m.ensureCursorVisible() } else { m.cursor = 0 m.stepCursors[m.step] = 0 @@ -611,6 +578,9 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.template = templates[m.cursor].Name } } + if m.step <= 1 { + m.ensureCursorVisible() + } } else { m.cursor = 0 if m.step == 0 { @@ -619,6 +589,9 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if m.step == 1 { m.template = "" } + if m.step <= 1 { + m.windowStart = 0 + } } m.projectName.Blur() m.description.Blur() @@ -652,6 +625,7 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.step++ m.cursor = m.stepCursors[m.step] m.projectName.Focus() + m.windowStart = 0 } else if m.step == 2 { if m.nameValidated && !m.checkingName && m.projectName.Value() != "" { // Store current cursor position before moving forward @@ -683,10 +657,7 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.step <= 1 { // Only for list views if m.cursor > 0 { m.cursor-- - // Ensure cursor is visible after moving up - if m.ready { - m.ensureCursorVisible() - } + m.ensureCursorVisible() } } if m.step == 2 { @@ -724,10 +695,7 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor < maxItems-1 { m.cursor++ - // Ensure cursor is visible after moving down - if m.ready { - m.ensureCursorVisible() - } + m.ensureCursorVisible() } } if m.step == 2 { @@ -785,6 +753,7 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Focus the project name input when entering step 2 m.projectName.Focus() m.nameEnter = false + m.windowStart = 0 } else if m.step == 2 { if m.projectName.Focused() { @@ -844,17 +813,7 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } - case "pgup": - if m.step <= 1 { - m.viewport.HalfViewUp() - m.updateCursorFromScroll() - } - - case "pgdown": - if m.step <= 1 { - m.viewport.HalfViewDown() - m.updateCursorFromScroll() - } + // Remove free scrolling keys case "tab", "shift+tab": if m.step == 2 { @@ -931,8 +890,12 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Handle viewport updates - if m.ready { + // Handle viewport updates: set YOffset based on windowStart so items are fully shown + if m.ready && m.step <= 1 { + // In paged mode the content is already sliced; keep offset at 0 + if m.viewport.YOffset != 0 { + m.viewport.SetYOffset(0) + } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) cmds = append(cmds, cmd) @@ -948,15 +911,14 @@ func (m projectFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Add helper methods for scroll handling func (m *projectFormModel) initViewport() { - // Fixed element heights (always including both scroll indicators) - titleBarHeight := 3 // Title + description + spacing - headerHeight := 4 // Step title + description + spacing - footerHeight := 4 // Help text + spacing - verticalMargins := 2 // Top and bottom margins - scrollIndicators := 4 // Space for both scroll indicators (↑ and ↓) + // Fixed element heights + titleBarHeight := 3 // Title + spacing + headerHeight := 4 // Step title + description + spacing + footerHeight := 4 // Help text + spacing + verticalMargins := 2 // Top and bottom margins - // Calculate total fixed height (including both scroll indicators) - totalFixedHeight := titleBarHeight + headerHeight + footerHeight + verticalMargins + scrollIndicators + // Calculate total fixed height + totalFixedHeight := titleBarHeight + headerHeight + footerHeight + verticalMargins // Calculate available height for viewport availableHeight := m.height - totalFixedHeight @@ -975,7 +937,15 @@ func (m *projectFormModel) initViewport() { m.viewport.Style = lipgloss.NewStyle().Padding(0, 1) // Add left/right padding within viewport // Set item height and mark model as ready - m.itemHeight = 6 // Each item takes 6 lines (title + description + spacing) + m.itemHeight = 3 // Each item uses 3 lines (title + description + spacing) + if availableHeight <= 0 { + m.windowSize = 0 + } else { + m.windowSize = availableHeight / m.itemHeight + if m.windowSize < 1 { + m.windowSize = 1 + } + } m.ready = true // Force initial update of viewport content @@ -1009,61 +979,59 @@ func (m *projectFormModel) initViewport() { } } + // Ensure selected option will be in frame after resize + m.ensureCursorVisible() m.updateViewportContent(content.String()) } } func (m *projectFormModel) ensureCursorVisible() { - if !m.ready { + if !m.ready || m.step > 1 { return } - // Calculate the actual position of the cursor in the content - cursorPos := m.cursor * m.itemHeight - - // Calculate visible area - visibleStart := m.viewport.YOffset - visibleEnd := visibleStart + m.viewport.Height - m.itemHeight - - // If cursor is above visible area, scroll up - if cursorPos < visibleStart { - m.viewport.SetYOffset(cursorPos) - } - - // If cursor is below visible area, scroll down - if cursorPos > visibleEnd { - // Set offset to show cursor at the bottom of viewport - m.viewport.SetYOffset(cursorPos - m.viewport.Height + m.itemHeight) + // Get the total number of items based on current step + var totalItems int + if m.step == 0 { + totalItems = len(m.choices) + } else if m.step == 1 { + if templates, ok := m.templates[m.runtime]; ok { + totalItems = len(templates) + } } -} -func (m *projectFormModel) updateCursorFromScroll() { - if !m.ready { + // Compute window so that selected item is fully visible + if m.windowSize <= 0 || totalItems == 0 { return } - // Update cursor based on scroll position - viewTop := m.viewport.YOffset + // If cursor above window, move windowStart up to cursor + if m.cursor < m.windowStart { + m.windowStart = m.cursor + } - // Find the first fully visible item - newCursor := viewTop / m.itemHeight + // If cursor beyond window end, shift window to include it + windowEnd := m.windowStart + m.windowSize - 1 + if m.cursor > windowEnd { + m.windowStart = m.cursor - (m.windowSize - 1) + } - // Ensure cursor stays within bounds - maxItems := 0 - if m.step == 0 { - maxItems = len(m.choices) - } else if m.step == 1 && m.runtime != "" { - maxItems = len(m.templates[m.runtime]) + if m.windowStart < 0 { + m.windowStart = 0 } - if newCursor >= maxItems { - newCursor = maxItems - 1 + // Clamp windowStart to ensure it doesn't exceed valid bounds + maxWindowStart := totalItems - m.windowSize + if maxWindowStart < 0 { + maxWindowStart = 0 } - if newCursor < 0 { - newCursor = 0 + if m.windowStart > maxWindowStart { + m.windowStart = maxWindowStart } +} - m.cursor = newCursor +func (m *projectFormModel) updateCursorFromScroll() { + // No free scroll; nothing to do } func (m projectFormModel) View() string { @@ -1177,9 +1145,33 @@ func (m projectFormModel) View() string { content.WriteString(descriptionStyle.UnsetWidth().UnsetPaddingLeft().Render(description)) content.WriteString("\n\n") - // Build scrollable content + // Build paged content: only full items that fit var scrollContent strings.Builder - for _, item := range items { + // Derive visible slice + totalItems := len(items) + if m.windowSize <= 0 { + m.windowSize = 1 + } + // Make sure selection is clamped and window follows selection + if m.cursor >= totalItems { + m.cursor = totalItems - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + m.ensureCursorVisible() + if m.windowStart < 0 { + m.windowStart = 0 + } + if m.windowStart > totalItems { + m.windowStart = 0 + } + end := m.windowStart + m.windowSize + if end > totalItems { + end = totalItems + } + visible := items[m.windowStart:end] + for _, item := range visible { if item.selected { scrollContent.WriteString(fmt.Sprintf("> %s\n", selectedItemStyle.Render(item.name))) scrollContent.WriteString(descriptionSelectedStyle.PaddingLeft(2).Render(item.desc) + "\n") @@ -1191,21 +1183,20 @@ func (m projectFormModel) View() string { } } - // Add scrollable content within viewport + // Add content within viewport if m.ready { m.updateViewportContent(scrollContent.String()) content.WriteString(m.viewport.View()) - - // Add scroll indicators if content exceeds viewport - if m.contentHeight > m.viewport.Height { + // Paged-mode indicators based on window position + if totalItems > m.windowSize { var indicators strings.Builder - if m.viewport.YOffset > 0 { + if m.windowStart > 0 { indicators.WriteString("↑") } else { indicators.WriteString(" ") } indicators.WriteString(" • ") - if m.viewport.YOffset+m.viewport.Height < m.contentHeight { + if m.windowStart+m.windowSize < totalItems { indicators.WriteString("↓") } else { indicators.WriteString(" ") @@ -1333,9 +1324,7 @@ func (m projectFormModel) View() string { // Help bar (fixed at bottom) help := []string{"↑ up", "↓ down"} - if m.step <= 1 { - help = append(help, "pgup/pgdn scroll") - } + // No free scrolling in list views switch m.step { case 2: if m.projectName.Value() != "" && m.nameValidated && !m.checkingName {