diff --git a/internal/browser/handlers.go b/internal/browser/handlers.go index 48e33dd..8458483 100644 --- a/internal/browser/handlers.go +++ b/internal/browser/handlers.go @@ -1,5 +1,12 @@ package browser +import ( + "path/filepath" + "strings" + + "pad/internal/ui" +) + // HandleScroll updates the browser scroll index by the given delta (in entries). // Clamps to valid bounds and triggers prefetch/eviction as needed. func HandleScroll(s *BrowserState, delta int) { @@ -41,3 +48,33 @@ func clampScrollOffset(s *BrowserState) { s.ScrollOffset = maxScroll } } + +// HandleBrowserTap processes a tap on the ListView at the given index. +func HandleBrowserTap(bm *BrowserManager, s *BrowserState, index int) { + if index < 0 || index >= s.TotalEntries { + return + } + + entry, ok := getEntryByIndex(s, index) + if !ok { + return // Page not loaded + } + + if entry.IsDir { + // Navigate into directory or up + var newPath string + if entry.Name == ".." { + newPath = filepath.Dir(s.CurrentPath) + } else { + newPath = filepath.Join(s.CurrentPath, entry.Name) + if !strings.HasPrefix(newPath, "/") { + newPath = "/" + newPath + } + } + bm.NavigateTo(newPath) + } else { + // Open file + s.SelectedIndex = index + ui.OpenFile(entry.Path) + } +} diff --git a/internal/browser/layout.go b/internal/browser/layout.go index e46717d..d30ba37 100644 --- a/internal/browser/layout.go +++ b/internal/browser/layout.go @@ -7,8 +7,8 @@ import ( ) // 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 { +// 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 @@ -79,7 +79,10 @@ func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState, sortHandler func listRegion, ui.Dp(state.ScrollOffset), // scroll offset in pixels (per-pixel) state.SelectedIndex, - []ui.Interaction{{Gesture: ui.Scroll, Handler: scrollHandler}}, + []ui.Interaction{ + {Gesture: ui.Scroll, Handler: scrollHandler}, + }, + tapHandler, ) elems := []ui.Element{headerBar, searchBar} diff --git a/internal/browser/layout_test.go b/internal/browser/layout_test.go index 019c38c..82364ca 100644 --- a/internal/browser/layout_test.go +++ b/internal/browser/layout_test.go @@ -63,7 +63,7 @@ func findListView(elements []ui.Element) (ui.ListView, bool) { func TestBrowserLayout_ElementCount(t *testing.T) { s := makeTestState(t, 200, 0, 20) - elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil) + elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil) // Should produce: header + search bar + list view = 3 elements if len(elements) < 3 { @@ -76,7 +76,7 @@ func TestBrowserLayout_ElementCount(t *testing.T) { func TestBrowserLayout_HeaderRegion(t *testing.T) { s := makeTestState(t, 200, 0, 20) - elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil) + elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil) // First element should be the header container if len(elements) == 0 { @@ -102,7 +102,7 @@ func TestBrowserLayout_HeaderRegion(t *testing.T) { func TestBrowserLayout_ListRegion(t *testing.T) { s := makeTestState(t, 200, 0, 20) - elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil) + elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil) listView, found := findListView(elements) if !found { @@ -127,7 +127,7 @@ func TestBrowserLayout_ListRegion(t *testing.T) { func TestBrowserLayout_VisibleEntriesCount(t *testing.T) { s := makeTestState(t, 500, 100, 20) - elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil) + elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil) listView, found := findListView(elements) if !found { @@ -149,7 +149,7 @@ func TestBrowserLayout_EmptyDirectory(t *testing.T) { s.CurrentPath = "/empty/dir" s.TotalEntries = 0 - elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil) + elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil) if len(elements) < 3 { t.Errorf("expected at least 3 elements for empty dir, got %d", len(elements)) @@ -179,7 +179,7 @@ func TestBrowserLayout_SingleEntry(t *testing.T) { } s.Pages[0] = NewPage(0, entries) - elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil) + elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil) listView, found := findListView(elements) if !found { @@ -214,7 +214,7 @@ func TestBrowserLayout_PartialPage(t *testing.T) { s.Pages[0] = NewPage(0, entries[:100]) s.Pages[1] = NewPage(1, entries[100:]) - elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil) + elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil) listView, found := findListView(elements) if !found { @@ -241,7 +241,7 @@ func TestBrowserLayout_UnloadedPage(t *testing.T) { entries := make([]Entry, 100) s.Pages[0] = NewPage(0, entries) - elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil) + elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil) // Layout should not crash with unloaded pages if len(elements) < 3 { @@ -262,7 +262,7 @@ func TestBrowserLayout_WithSearchQuery(t *testing.T) { } } - elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil) + elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil) // Should still produce valid layout if len(elements) < 3 { @@ -278,7 +278,7 @@ func TestBrowserLayout_ScreenDimensions(t *testing.T) { // Test with different screen sizes for _, w := range []ui.Dp{400, 800, 1200} { for _, h := range []ui.Dp{600, 1200, 1800} { - elements := BrowserLayout(w, h, s, nil) + elements := BrowserLayout(w, h, s, nil, nil) if len(elements) < 3 { t.Errorf("layout failed for %dx%d: expected >= 3 elements, got %d", int(w), int(h), len(elements)) diff --git a/internal/browser/manager.go b/internal/browser/manager.go index feb4230..ffe37b2 100644 --- a/internal/browser/manager.go +++ b/internal/browser/manager.go @@ -3,6 +3,7 @@ package browser import ( "fmt" "path/filepath" + "time" "pad/internal/io/pool" "pad/internal/io/pool/mock" @@ -94,6 +95,17 @@ func (bm *BrowserManager) handleBuildIndexSuccess(result pool.Result) { entries := result.Data.([]mock.DirEntry) var browserEntries []Entry + // Add ".." entry if not at root + if bm.dirPath != "/" && bm.dirPath != "." { + browserEntries = append(browserEntries, Entry{ + Path: filepath.Dir(bm.dirPath), + Name: "..", + Size: 0, + ModTime: time.Now(), + IsDir: true, + }) + } + for _, e := range entries { info, _ := e.Info() browserEntries = append(browserEntries, Entry{ diff --git a/internal/browser/navigation_test.go b/internal/browser/navigation_test.go new file mode 100644 index 0000000..d01cd93 --- /dev/null +++ b/internal/browser/navigation_test.go @@ -0,0 +1,60 @@ +package browser + +import ( + "testing" + "time" + + "pad/internal/io/pool" + "pad/internal/io/pool/mock" +) + +// TestTapDirectory verifies that tapping a directory entry updates the +// BrowserState.CurrentPath. +func TestTapDirectory(t *testing.T) { + state := NewBrowserState() + fs := mock.NewFileSystem() + wp := pool.NewWorkerPool(1) + bm, _ := NewBrowserManager(state, wp, fs) + state.CurrentPath = "/root" + + // Mock entries: a subdirectory "sub" and a file + entries := []Entry{ + NewEntry("/root/sub", "sub", 0, time.Time{}, true), + NewEntry("/root/file.txt", "file.txt", 100, time.Time{}, false), + } + // Inject entries into the state + state.Pages[0] = NewPage(0, entries) + state.TotalEntries = 2 + + // Simulate tapping the directory at index 0 + HandleBrowserTap(bm, state, 0) + + if state.CurrentPath != "/root/sub" { + t.Errorf("expected CurrentPath='/root/sub', got %s", state.CurrentPath) + } +} + +// TestTapUp verifies that tapping a ".." entry updates the +// BrowserState.CurrentPath to the parent directory. +func TestTapUp(t *testing.T) { + state := NewBrowserState() + fs := mock.NewFileSystem() + wp := pool.NewWorkerPool(1) + bm, _ := NewBrowserManager(state, wp, fs) + state.CurrentPath = "/root/sub" + + // Mock entries: ".." and a file + entries := []Entry{ + NewEntry("/root", "..", 0, time.Time{}, true), + NewEntry("/root/sub/file.txt", "file.txt", 100, time.Time{}, false), + } + state.Pages[0] = NewPage(0, entries) + state.TotalEntries = 2 + + // Simulate tapping the ".." entry at index 0 + HandleBrowserTap(bm, state, 0) + + if state.CurrentPath != "/root" { + t.Errorf("expected CurrentPath='/root', got %s", state.CurrentPath) + } +} diff --git a/internal/editor/logic.go b/internal/editor/logic.go index db9ffe0..3493875 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -142,17 +142,17 @@ func (l *Logic) Run() { return case update := <-l.configChan: update.apply(l.state) - l.frameChan <- l.state.layout() + l.frameChan <- l.state.layout(l.browserManager) case y := <-l.lastLineYChan: if ui.Dp(y) != l.state.LastLineY { l.state.LastLineY = ui.Dp(y) - l.frameChan <- l.state.layout() + l.frameChan <- l.state.layout(l.browserManager) } case events := <-l.inputChan: for _, evt := range events { evt.Handler(evt.Data) } - l.frameChan <- l.state.layout() + l.frameChan <- l.state.layout(l.browserManager) case query := <-l.searchQueryChan: if query != l.state.Browser.Query { l.state.Browser.Query = query @@ -160,11 +160,11 @@ func (l *Logic) Run() { browser.HandleSearch(&l.state.Browser, query) } } - l.frameChan <- l.state.layout() + l.frameChan <- l.state.layout(l.browserManager) case res := <-l.workerPool.ResultChan(): l.handleWorkerResult(res) case <-l.resultChan: - l.frameChan <- l.state.layout() + l.frameChan <- l.state.layout(l.browserManager) } } } @@ -174,7 +174,7 @@ func (l *Logic) handleWorkerResult(res pool.Result) { if res.IsBrowserResult() { l.browserManager.HandleResult(res) } - l.frameChan <- l.state.layout() + l.frameChan <- l.state.layout(l.browserManager) } // applyBuildIndexResult applies a completed BuildIndexTask result to browser state. diff --git a/internal/editor/state.go b/internal/editor/state.go index 647d8c0..97db040 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -96,7 +96,7 @@ func (s *State) Scale() float32 { // and computes the element tree. Called only when a frame is needed. // Search query sync is handled by the logic goroutine via searchQueryChan, // not here, to ensure proper channel-based state flow. -func (s *State) layout() []ui.Element { +func (s *State) layout(bm *browser.BrowserManager) []ui.Element { dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale) dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale) @@ -114,7 +114,12 @@ func (s *State) layout() []ui.Element { switch s.page { case BrowserPage: - s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder) + tapHandler := func(data any) { + if idx, ok := data.(int); ok { + browser.HandleBrowserTap(bm, &s.Browser, idx) + } + } + s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder, tapHandler) case EditorPage: s.Elems = EditorLayout(dpW, dpH, s.WordWrap) } diff --git a/internal/ui/element.go b/internal/ui/element.go index 2981f4d..53af837 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -234,7 +234,7 @@ type ListView struct { Items []ListItem ScrollOffset Dp // pixel-level scroll offset Selected int - RowFilenames []string // filenames for click navigation + RowTapHandler func(any) // handler for row taps, receives index as any } func (lv ListView) Region() Region { return lv.region } @@ -299,15 +299,15 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) { } // Register click area for this row rowID := fmt.Sprintf("list_row_%d", rowGlobalIndex) - var filename string - if i < len(lv.RowFilenames) { - filename = lv.RowFilenames[i] - } r.RegisterClick(gtx, rowID, Region{ X: lv.region.X, Y: y, W: lv.region.W, H: rowHeight, }, func(data any) { - OpenFile(filename) + if lv.RowTapHandler != nil { + lv.RowTapHandler(rowGlobalIndex) + } else { + OpenFile(item.Text) + } }) // Draw main text textRegion := Region{ @@ -331,15 +331,16 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) { } // NewListView creates a visible ListView element. -func NewListView(id string, items []ListItem, region Region, scrollOffset Dp, selected int, interactions []Interaction) ListView { +func NewListView(id string, items []ListItem, region Region, scrollOffset Dp, selected int, interactions []Interaction, rowTapHandler func(any)) ListView { return ListView{ - id: id, - region: region, - visible: true, - interactions: interactions, - Items: items, - ScrollOffset: scrollOffset, - Selected: selected, + id: id, + region: region, + visible: true, + interactions: interactions, + Items: items, + ScrollOffset: scrollOffset, + Selected: selected, + RowTapHandler: rowTapHandler, } }