update:scrollable list

This commit is contained in:
2026-04-18 04:31:10 +09:00
parent 5f440f11ff
commit 753329e2c8
6 changed files with 165 additions and 20 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.env
dist
.pi

View File

@@ -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 {

88
internal/tui/scroll.go Normal file
View 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
}

View File

@@ -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
}

View File

@@ -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++

View File

@@ -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),
)