Selection handles now track the finger 1:1 (anchor grab point + displacement) instead of snapping by whole lines, and crossing the opposite handle flips the selection (native behaviour) instead of clearing it. Caret and tap/handle line resolution use VisualLineStarts instead of the min-Y baseline: the window's first visual line may be an empty line with no recorded glyphs, which used to draw boundary carets one line too low per leading empty line and land taps/dragged handles one line below the finger. New exported ui.CaretPoint centralises byte->insertion-point mapping. The off-screen caret no longer clamps to the window edge: EditorLayout ships the true (possibly negative / past-end) window-relative cursor and the renderer skips the caret when the cursor is outside the shaped window, so scrolling past the caret no longer makes it jump onto the top/bottom line. IME/router replay fixes: key.FocusCmd is issued only on a focus transition (a per-frame no-op still takes the immediate-command path and re-queues all pointer events), and the key.SelectionCmd IME sync is deferred while a handle drag is in progress (each push re-injected the drag into every gesture). Handle drags forward only Grabbed events; a tap inside a handle grab box is a no-op. Also: key.FocusEvent no longer logs as unexpected in main; dead code removed (worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses strings.HasPrefix; build scripts run the new scripts/check.sh static gate (go vet + staticcheck). Tests: caret_point_test, touch_selection updates (flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
243 lines
6.7 KiB
Go
243 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
|
|
}
|