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.
157 lines
4.8 KiB
Go
157 lines
4.8 KiB
Go
package browser
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// Entry represents a single file or directory in the browser.
|
|
type Entry struct {
|
|
Path string // Relative path from configured directory
|
|
Name string // Display name (basename)
|
|
Size int64 // File size in bytes (0 for directories)
|
|
ModTime time.Time // Last modification time
|
|
IsDir bool // True if this is a directory
|
|
IsFirst bool // True if this is the first entry for its letter section
|
|
}
|
|
|
|
// Page represents a chunk of directory entries loaded from disk.
|
|
type Page struct {
|
|
Index int // Page number (0-based)
|
|
Entries []Entry // Entries in this page
|
|
Loaded bool // True if page data is in memory
|
|
Dirty bool // True if page needs refresh (external change detected)
|
|
}
|
|
|
|
// BrowserState holds all mutable browser state owned by the logic goroutine.
|
|
type BrowserState struct {
|
|
// Navigation
|
|
CurrentPath string // Currently browsed directory (relative to root)
|
|
History []string // Path history for navigation
|
|
ScrollOffset float64 // Vertical scroll offset in pixels (per-pixel scrolling)
|
|
EntryHeight float64 // Height of a single entry in pixels
|
|
VisibleCount int // Number of entries currently visible
|
|
|
|
// Lazy loading
|
|
Pages map[int]*Page // Loaded pages by page index
|
|
TotalEntries int // Total entry count (from cached index)
|
|
Loading bool // True if a page load is in flight
|
|
|
|
// Sorted index (position maps for each sort mode)
|
|
SortIndex *DirectoryIndex // Cached index with position maps
|
|
SortMode SortMode // Current sort mode
|
|
|
|
// Search
|
|
Query string // Current search query (forwarded from the main-owned search widget)
|
|
SearchResults []int // Indices of matching entries (empty = no filter)
|
|
// NOTE: the search bar's widget.Editor is intentionally NOT part of this
|
|
// state: Gio mutates widget state on the main goroutine during draw, and
|
|
// this state is owned by the logic goroutine (architecture.md §1). The
|
|
// widget lives in the renderer; its text is forwarded to the logic
|
|
// goroutine via the searchQuery channel, landing in Query.
|
|
|
|
// Alphabetical index
|
|
ActiveLetter string // Currently pressed letter (for highlighting)
|
|
LetterOffsets map[string]int // First entry index for each letter
|
|
|
|
// Interaction
|
|
SelectedIndex int // Currently selected entry (-1 = none)
|
|
TapTimestamp time.Time // For double-tap detection
|
|
}
|
|
|
|
const (
|
|
PageSize = 100 // Entries per page (tunable; ~5KB per page in memory)
|
|
PrefetchDist = 2 // Pages to prefetch beyond visible region
|
|
)
|
|
|
|
// NewEntry creates a new Entry from the given parameters.
|
|
func NewEntry(path, name string, size int64, modTime time.Time, isDir bool) Entry {
|
|
return Entry{
|
|
Path: path,
|
|
Name: name,
|
|
Size: size,
|
|
ModTime: modTime,
|
|
IsDir: isDir,
|
|
}
|
|
}
|
|
|
|
// NewPage creates a new Page with the given index and entries.
|
|
func NewPage(index int, entries []Entry) *Page {
|
|
return &Page{
|
|
Index: index,
|
|
Entries: entries,
|
|
Loaded: true,
|
|
}
|
|
}
|
|
|
|
// NewBrowserState creates a new BrowserState with default values.
|
|
func NewBrowserState() *BrowserState {
|
|
return &BrowserState{
|
|
Pages: make(map[int]*Page),
|
|
LetterOffsets: make(map[string]int),
|
|
SelectedIndex: -1,
|
|
SortMode: SortModeDateDesc, // Default sort mode (newest first)
|
|
EntryHeight: 48.0,
|
|
CurrentPath: "/",
|
|
}
|
|
}
|
|
|
|
// ToListItem converts an Entry to a ui.ListItem for rendering.
|
|
func (e *Entry) ToListItem() ui.ListItem {
|
|
subtext := formatSize(e.Size)
|
|
if e.IsDir {
|
|
subtext = "Directory"
|
|
}
|
|
return ui.ListItem{
|
|
Text: e.Name,
|
|
Subtext: subtext,
|
|
}
|
|
}
|
|
|
|
// formatSize returns a human-readable file size string.
|
|
func formatSize(bytes int64) string {
|
|
switch {
|
|
case bytes < 1024:
|
|
return fmt.Sprintf("%d B", bytes)
|
|
case bytes < 1024*1024:
|
|
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
|
|
case bytes < 1024*1024*1024:
|
|
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
|
|
default:
|
|
return fmt.Sprintf("%.1f GB", float64(bytes)/(1024*1024*1024))
|
|
}
|
|
}
|
|
|
|
// EvictPages removes pages that are far from the current scroll position.
|
|
func (s *BrowserState) EvictPages() {
|
|
minPage := s.GetScrollIndex()/PageSize - PrefetchDist
|
|
maxPage := (s.GetScrollIndex() + s.VisibleCount)/PageSize + PrefetchDist
|
|
for idx, page := range s.Pages {
|
|
if idx < minPage || idx > maxPage {
|
|
page.Entries = nil // Release memory
|
|
page.Loaded = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetScrollIndex returns the index of the first visible entry, derived from
|
|
// the per-pixel ScrollOffset and EntryHeight.
|
|
func (s *BrowserState) GetScrollIndex() int {
|
|
if s.EntryHeight <= 0 {
|
|
return 0
|
|
}
|
|
return int(s.ScrollOffset / s.EntryHeight)
|
|
}
|
|
|
|
// Reset clears all browser state for navigation.
|
|
func (s *BrowserState) Reset() {
|
|
s.CurrentPath = ""
|
|
s.Pages = make(map[int]*Page)
|
|
s.ScrollOffset = 0
|
|
s.SelectedIndex = -1
|
|
s.SearchResults = nil
|
|
s.Query = ""
|
|
}
|