update:scrollable list
This commit is contained in:
88
internal/tui/scroll.go
Normal file
88
internal/tui/scroll.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user