package browser import ( "strings" "pad/internal/ui" ) // BrowserLayout computes the element tree for the browser (file listing) page. // Pure function: (screen dimensions, browser state, sort handler) → []ui.Element func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState, sortHandler 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, } state.SearchEditor.SingleLine = true searchBar := ui.NewGioEditor("search_bar", searchRegion, &state.SearchEditor) var searchPlaceholder ui.Element if state.SearchEditor.Len() == 0 { searchPlaceholder = ui.NewLabel("Search…", 14, ui.Region{X: margin + ui.Dp(8), Y: searchY + ui.Dp(8), W: contentWidth - ui.Dp(16), H: searchHeight - ui.Dp(16)}, ui.AlignStart, "", nil) } // --- 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}}, ) elems := []ui.Element{headerBar, searchBar} if searchPlaceholder != nil { elems = append(elems, searchPlaceholder) } 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.SortIndex == nil { return } queryLower := strings.ToLower(s.Query) var results []int // Build a map of entry names to their new sorted indices // We need to find which entries match the query in the new sort order positionMap := s.getSortedIndices() if positionMap == nil { return } // For each raw entry, check if it matches the query // and add its new sorted index to results for sortedIdx, rawIdx := range positionMap { if rawIdx >= len(s.SortIndex.Entries) { continue } entry := s.SortIndex.Entries[rawIdx] if strings.Contains(strings.ToLower(entry.Name), queryLower) { results = append(results, sortedIdx) } } s.SearchResults = results // Jump to first result if any matches found if len(results) > 0 { s.ScrollOffset = float64(results[0]) * s.EntryHeight } } // 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 len(state.SearchResults) > 0 { return computeSearchResults(state) } startIndex := state.GetScrollIndex() endIndex := startIndex + state.VisibleCount 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. func computeSearchResults(state *BrowserState) []ui.ListItem { var items []ui.ListItem for _, idx := range state.SearchResults { if idx < 0 || idx >= state.TotalEntries { continue } 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 }