The tree was formatted with an older gofmt; go1.27's gofmt additionally wants: EOF exactly one newline (no trailing blank lines), imports sorted alphabetically within a block, mixed-precedence binary expressions re-spaced for grouping ((a+b)/c), single-field composite literals un-aligned, adjacent one-line method signatures aligned, and one-line bodies containing a compound statement expanded. Applied repo-wide (31 files under internal/); pure formatting, no semantic changes — build and the full test suite pass.
241 lines
6.7 KiB
Go
241 lines
6.7 KiB
Go
package browser
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// BrowserLayout computes the element tree for the browser (file listing) page.
|
|
// Pure function: (screen dimensions, browser state, sort handler, tap handler) → []ui.Element
|
|
func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState, sortHandler func(any), tapHandler func(any)) []ui.Element {
|
|
margin := ui.Dp(10)
|
|
contentWidth := screenW - margin*2
|
|
|
|
// --- Header bar: directory path + sort toggle ---
|
|
headerHeight := ui.Dp(24)
|
|
headerRegion := ui.Region{
|
|
X: margin, Y: margin,
|
|
W: contentWidth, H: headerHeight,
|
|
}
|
|
sortLabel := sortModeLabel(state.SortMode)
|
|
headerBar := ui.NewContainer(
|
|
headerRegion,
|
|
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
|
[]ui.Element{
|
|
ui.NewLabel(state.CurrentPath, 14, ui.Region{X: 0, Y: 0, W: contentWidth, H: headerHeight}, ui.AlignStart, "", nil),
|
|
ui.NewLabel(sortLabel, 12, ui.Region{X: 0, Y: 0, W: contentWidth, H: headerHeight}, ui.AlignEnd, "sort",
|
|
[]ui.Interaction{{Gesture: ui.Tap, Handler: sortHandler}}),
|
|
},
|
|
)
|
|
|
|
// --- Search bar (Gio editor) ---
|
|
searchHeight := ui.Dp(36)
|
|
searchY := headerRegion.Y + headerRegion.H + margin/2
|
|
searchRegion := ui.Region{
|
|
X: margin, Y: searchY,
|
|
W: contentWidth, H: searchHeight,
|
|
}
|
|
// The search widget itself is owned by the main goroutine and registered
|
|
// with the renderer by ID ("search_bar"); the logic goroutine only sees
|
|
// its text via the searchQuery channel (state.Query).
|
|
searchBar := ui.NewGioEditor("search_bar", searchRegion)
|
|
|
|
// --- ListView ---
|
|
listY := searchY + searchHeight + margin/2
|
|
listHeight := screenH - listY - margin
|
|
listRegion := ui.Region{
|
|
X: margin, Y: listY,
|
|
W: contentWidth, H: listHeight,
|
|
}
|
|
|
|
// Compute visible entries
|
|
visibleEntries := computeVisibleEntries(state)
|
|
|
|
// Create a scroll handler that captures the current browser state
|
|
scrollHandler := func(data any) {
|
|
var delta int
|
|
switch v := data.(type) {
|
|
case int:
|
|
delta = v
|
|
case float32:
|
|
delta = int(v)
|
|
case float64:
|
|
delta = int(v)
|
|
default:
|
|
return
|
|
}
|
|
HandlePixelScroll(state, delta)
|
|
}
|
|
|
|
listView := ui.NewListView(
|
|
"browser_list",
|
|
visibleEntries,
|
|
listRegion,
|
|
ui.Dp(state.ScrollOffset), // scroll offset in pixels (per-pixel)
|
|
state.SelectedIndex,
|
|
[]ui.Interaction{
|
|
{Gesture: ui.Scroll, Handler: scrollHandler},
|
|
},
|
|
tapHandler,
|
|
)
|
|
|
|
elems := []ui.Element{headerBar, searchBar}
|
|
elems = append(elems, listView)
|
|
return elems
|
|
}
|
|
|
|
// sortModeLabel returns the display label for the given sort mode.
|
|
func sortModeLabel(mode SortMode) string {
|
|
switch mode {
|
|
case SortModeNameAsc:
|
|
return "Name ↑"
|
|
case SortModeNameDesc:
|
|
return "Name ↓"
|
|
case SortModeDateAsc:
|
|
return "Date ↑"
|
|
case SortModeDateDesc:
|
|
return "Date ↓"
|
|
default:
|
|
return "Name ↑"
|
|
}
|
|
}
|
|
|
|
// HandleSortModeChange recomputes search results when the sort mode changes.
|
|
// This ensures SearchResults indices remain valid after pages are reloaded
|
|
// with the new sort order.
|
|
func HandleSortModeChange(s *BrowserState) {
|
|
// If there's an active search, recompute the results with the new sort order
|
|
if s.Query != "" && s.SortIndex != nil {
|
|
recomputeSearchResults(s)
|
|
}
|
|
}
|
|
|
|
// recomputeSearchResults rebuilds SearchResults based on the current sort order.
|
|
// It checks each entry in the SortIndex against the query and updates the indices
|
|
// to reflect the new sort order.
|
|
func recomputeSearchResults(s *BrowserState) {
|
|
if s.Query == "" {
|
|
s.SearchResults = nil
|
|
return
|
|
}
|
|
|
|
queryLower := strings.ToLower(s.Query)
|
|
var results []int
|
|
|
|
if s.SortIndex != nil && s.SortIndex.SortOrders != nil {
|
|
// Use SortIndex for searching (production path)
|
|
positionMap := s.getSortedIndices()
|
|
for sortedIdx, rawIdx := range positionMap {
|
|
if rawIdx < 0 || rawIdx >= len(s.SortIndex.Entries) {
|
|
continue
|
|
}
|
|
entry := s.SortIndex.Entries[rawIdx]
|
|
if strings.Contains(strings.ToLower(entry.Name), queryLower) {
|
|
results = append(results, sortedIdx)
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback: search through loaded Pages (used in tests without SortIndex)
|
|
for _, page := range s.Pages {
|
|
for i, entry := range page.Entries {
|
|
if strings.Contains(strings.ToLower(entry.Name), queryLower) {
|
|
results = append(results, page.Index*PageSize+i)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort the result indices for consistent ordering
|
|
sort.Ints(results)
|
|
|
|
s.SearchResults = results
|
|
|
|
// Jump to first result if any matches found
|
|
if len(results) > 0 {
|
|
s.ScrollOffset = 0 // Search results start at the top
|
|
} else {
|
|
s.ScrollOffset = 0
|
|
}
|
|
}
|
|
|
|
// ComputeVisibleEntriesForTest is exported for testing purposes.
|
|
// It builds the list of ui.ListItem entries that should be rendered.
|
|
func ComputeVisibleEntriesForTest(state *BrowserState) []ui.ListItem {
|
|
return computeVisibleEntries(state)
|
|
}
|
|
|
|
// computeVisibleEntries builds the list of ui.ListItem entries that should be
|
|
// rendered based on the current scroll position and visible count.
|
|
// When SearchResults is non-empty, only matching entries are shown.
|
|
func computeVisibleEntries(state *BrowserState) []ui.ListItem {
|
|
if state.TotalEntries == 0 {
|
|
return nil
|
|
}
|
|
|
|
// When search is active, show only matching entries
|
|
if state.Query != "" {
|
|
return computeSearchResults(state)
|
|
}
|
|
|
|
if state.VisibleCount == 0 {
|
|
return nil
|
|
}
|
|
|
|
startIndex := state.GetScrollIndex()
|
|
// show one past the "visible count" for a partial view of the next item
|
|
endIndex := startIndex + state.VisibleCount + 1
|
|
if endIndex > state.TotalEntries {
|
|
endIndex = state.TotalEntries
|
|
}
|
|
|
|
var items []ui.ListItem
|
|
for idx := startIndex; idx < endIndex; idx++ {
|
|
entry, ok := getEntryByIndex(state, idx)
|
|
if !ok {
|
|
// Page not loaded yet; add placeholder
|
|
items = append(items, ui.ListItem{
|
|
Text: "...",
|
|
Subtext: "Loading...",
|
|
})
|
|
continue
|
|
}
|
|
item := entry.ToListItem()
|
|
item.Selected = (idx == state.SelectedIndex)
|
|
items = append(items, item)
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
// computeSearchResults builds the list of ui.ListItem entries for search results.
|
|
// Only entries whose indices are in SearchResults are shown, based on the current scroll offset.
|
|
func computeSearchResults(state *BrowserState) []ui.ListItem {
|
|
startIndex := state.GetScrollIndex()
|
|
// show one past the "visible count" for a partial view of the next item
|
|
endIndex := startIndex + state.VisibleCount + 1
|
|
if endIndex > len(state.SearchResults) {
|
|
endIndex = len(state.SearchResults)
|
|
}
|
|
|
|
var items []ui.ListItem
|
|
for idx := startIndex; idx < endIndex; idx++ {
|
|
rawIdx := state.SearchResults[idx]
|
|
|
|
entry, ok := getEntryByIndex(state, rawIdx)
|
|
if !ok {
|
|
// Page not loaded yet; add placeholder
|
|
items = append(items, ui.ListItem{
|
|
Text: "...",
|
|
Subtext: "Loading...",
|
|
})
|
|
continue
|
|
}
|
|
item := entry.ToListItem()
|
|
item.Selected = (idx == state.SelectedIndex)
|
|
items = append(items, item)
|
|
}
|
|
return items
|
|
}
|