diff --git a/.gitignore b/.gitignore index 5bfcd1f..fd9f1b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env dist +.pi diff --git a/internal/styles/styles.go b/internal/styles/styles.go index 734a4a6..1e97da5 100644 --- a/internal/styles/styles.go +++ b/internal/styles/styles.go @@ -126,12 +126,12 @@ var ( // Storage file list StorageFileSectionStyle = lipgloss.NewStyle(). - BorderTop(true). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("237")). - MarginTop(1). - PaddingTop(1). - Width(44) + BorderTop(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("237")). + MarginTop(1). + PaddingTop(1). + Width(44) StorageEmptyStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("243")). @@ -143,11 +143,11 @@ var ( Width(44) fileItemActive = lipgloss.NewStyle(). - PaddingLeft(2). - Foreground(lipgloss.Color("75")). - Bold(true). - Width(44). - SetString("▸ ") + PaddingLeft(2). + Foreground(lipgloss.Color("75")). + Bold(true). + Width(44). + SetString("▸ ") FilenameLabelStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("243")). @@ -155,9 +155,9 @@ var ( // Local directory label (above file list and in picker breadcrumb) LocalDirStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("243")). - Italic(true). - MarginBottom(1) + Foreground(lipgloss.Color("243")). + Italic(true). + MarginBottom(1) // File picker PickerQueryStyle = lipgloss.NewStyle(). @@ -169,8 +169,8 @@ var ( MarginBottom(1) pickerItemBase = lipgloss.NewStyle(). - PaddingLeft(4). - Width(44) + PaddingLeft(4). + Width(44) pickerItemActive = lipgloss.NewStyle(). PaddingLeft(2). @@ -181,6 +181,9 @@ var ( pickerDirColor = lipgloss.Color("75") pickerFileColor = lipgloss.Color("252") + + ScrollIndicatorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) ) func MenuItemStyle(active, disabled bool) lipgloss.Style { diff --git a/internal/tui/scroll.go b/internal/tui/scroll.go new file mode 100644 index 0000000..215eb5f --- /dev/null +++ b/internal/tui/scroll.go @@ -0,0 +1,88 @@ +package tui + +// visibleRange returns [start, end) indices of rows that should be rendered. +func visibleRange(total, selected, scrollOff, viewHeight int) (int, int) { + if total <= 0 { + return 0, 0 + } + + maxVis := viewHeight + if maxVis <= 0 { + maxVis = total // unconstrained + } + if maxVis >= total { + return 0, total + } + + // Reserve lines for scroll indicators. + hasAbove := scrollOff > 0 + hasBelow := scrollOff+maxVis < total + if hasAbove { + maxVis-- + } + if hasBelow { + maxVis-- + } + if maxVis < 1 { + maxVis = 1 + } + + start := scrollOff + end := start + maxVis + + if selected < start { + start = selected + end = start + maxVis + } + if selected >= end { + end = selected + 1 + start = end - maxVis + } + if start < 0 { + start = 0 + end = min(maxVis, total) + } + if end > total { + end = total + start = max(0, end-maxVis) + } + + return start, end +} + +// syncScroll adjusts scrollOff so selected stays inside the viewport. +func syncScroll(selected, total, viewHeight, scrollOff int) int { + if total <= 0 { + return 0 + } + maxVis := viewHeight + if maxVis <= 0 || maxVis >= total { + return 0 + } + + if selected < 0 { + selected = 0 + } + if selected >= total { + selected = total - 1 + } + + if scrollOff < 0 { + scrollOff = 0 + } + if scrollOff > total-1 { + scrollOff = total - 1 + } + + if selected < scrollOff { + scrollOff = selected + } + if selected >= scrollOff+maxVis { + scrollOff = selected - maxVis + 1 + } + + if scrollOff < 0 { + scrollOff = 0 + } + return scrollOff +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c3c9027..cc83877 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -46,6 +46,8 @@ type TUIInterface struct { StorageErr error FileSelected int // cursor within StorageFiles FileFocused bool // true = ↑↓ drives file list, false = action menu + FileScrollOff int // first visible row in StorageFiles list + FileViewHeight int // available visible rows for StorageFiles list // file action page ActiveFile string FileOpLoading bool @@ -54,9 +56,9 @@ type TUIInterface struct { // edit server page EditingServer string // original name of server being edited // clean all confirmation page - CleanInput textinput.Model + CleanInput textinput.Model CleanOpLoading bool - CleanOpErr error + CleanOpErr error // send / file picker page Picker picker } @@ -69,3 +71,32 @@ func NewTUIInterface(store *services.ServicesStore, localDir string) TUIInterfac LocalDir: localDir, } } + +// fileListHeight calculates how many rows can fit in the server storage file list. +func (m TUIInterface) fileListHeight() int { + h := m.WindowHeight + if h == 0 { + return 0 // unconstrained until first WindowSizeMsg + } + + // Card chrome overhead (border top+bottom, inner padding top+bottom) + const cardOverhead = 6 + // Header (title + subtitle + margins) + const headerLines = 4 + // Footer (border + content) + const footerLines = 2 + // Server actions menu rows (Send / Clean All) + actionLines := len(m.MenuItems) + if actionLines < 1 { + actionLines = 2 + } + // File section chrome: section margin+border+padding + local-dir label+margin + const fileSectionOverhead = 5 + + used := cardOverhead + headerLines + footerLines + actionLines + fileSectionOverhead + available := h - used + if available < 1 { + available = 1 + } + return available +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 988f44f..2160b85 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -193,6 +193,8 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Selected = 0 m.FileSelected = 0 m.FileFocused = true + m.FileViewHeight = m.fileListHeight() + m.FileScrollOff = 0 m.StorageFiles = nil m.StorageErr = nil m.StorageLoading = true @@ -303,6 +305,7 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.StorageFiles = msg.files m.StorageErr = msg.err m.FileSelected = 0 + m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, 0) return m, nil case configLoadedMsg: @@ -332,6 +335,8 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.WindowWidth = msg.Width m.WindowHeight = msg.Height + m.FileViewHeight = m.fileListHeight() + m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, m.FileScrollOff) return m, nil case tea.KeyPressMsg: @@ -388,7 +393,7 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return pages.SelectEditServerPageMsg{} } case "remove": return m, func() tea.Msg { return pages.RemoveServerPageMsg{} } - // TODO: "edit" + // TODO: "edit" } case "ctrl+c": m.Quitting = true @@ -420,6 +425,9 @@ func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.C case "tab": if len(m.StorageFiles) > 0 || !m.StorageLoading { m.FileFocused = !m.FileFocused + if m.FileFocused { + m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, m.FileScrollOff) + } } case "up", "k": @@ -427,6 +435,7 @@ func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.C if m.FileSelected > 0 { m.FileSelected-- } + m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, m.FileScrollOff) } else { if m.Selected > 0 { m.Selected-- @@ -438,6 +447,7 @@ func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.C if m.FileSelected < len(m.StorageFiles)-1 { m.FileSelected++ } + m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, m.FileScrollOff) } else { if m.Selected < len(m.MenuItems)-1 { m.Selected++ diff --git a/internal/tui/view.go b/internal/tui/view.go index 6bb5e5f..f7ee7a2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -220,6 +220,8 @@ func (m TUIInterface) viewServerActions() string { // file list rows var fileRows []string + showScrollUp := false + showScrollDown := false switch { case m.StorageLoading: fileRows = append(fileRows, styles.StatusWarnStyle.Render(" loading…")) @@ -228,12 +230,22 @@ func (m TUIInterface) viewServerActions() string { case len(m.StorageFiles) == 0: fileRows = append(fileRows, styles.StorageEmptyStyle.Render(" no files in storage")) default: - for i, f := range m.StorageFiles { + start, end := visibleRange(len(m.StorageFiles), m.FileSelected, m.FileScrollOff, m.FileViewHeight) + showScrollUp = start > 0 + showScrollDown = end < len(m.StorageFiles) + for i := start; i < end; i++ { + f := m.StorageFiles[i] active := m.FileFocused && i == m.FileSelected fileRows = append(fileRows, styles.FileItemStyle(active).Render(f)) } } fileList := lipgloss.JoinVertical(lipgloss.Left, fileRows...) + if showScrollUp { + fileList = styles.ScrollIndicatorStyle.Render(" ↑") + "\n" + fileList + } + if showScrollDown { + fileList = fileList + "\n" + styles.ScrollIndicatorStyle.Render(" ↓") + } fileSection := styles.StorageFileSectionStyle.Render( lipgloss.JoinVertical(lipgloss.Left, localDirLabel, fileList), )