fix(browser): fix directory navigation and entry tapping
This commit is contained in:
parent
99b733cb7d
commit
2c3702f421
|
|
@ -1,5 +1,12 @@
|
||||||
package browser
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"pad/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
// HandleScroll updates the browser scroll index by the given delta (in entries).
|
// HandleScroll updates the browser scroll index by the given delta (in entries).
|
||||||
// Clamps to valid bounds and triggers prefetch/eviction as needed.
|
// Clamps to valid bounds and triggers prefetch/eviction as needed.
|
||||||
func HandleScroll(s *BrowserState, delta int) {
|
func HandleScroll(s *BrowserState, delta int) {
|
||||||
|
|
@ -41,3 +48,33 @@ func clampScrollOffset(s *BrowserState) {
|
||||||
s.ScrollOffset = maxScroll
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// BrowserLayout computes the element tree for the browser (file listing) page.
|
// BrowserLayout computes the element tree for the browser (file listing) page.
|
||||||
// Pure function: (screen dimensions, browser state, sort handler) → []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)) []ui.Element {
|
func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState, sortHandler func(any), tapHandler func(any)) []ui.Element {
|
||||||
margin := ui.Dp(10)
|
margin := ui.Dp(10)
|
||||||
contentWidth := screenW - margin*2
|
contentWidth := screenW - margin*2
|
||||||
|
|
||||||
|
|
@ -79,7 +79,10 @@ func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState, sortHandler func
|
||||||
listRegion,
|
listRegion,
|
||||||
ui.Dp(state.ScrollOffset), // scroll offset in pixels (per-pixel)
|
ui.Dp(state.ScrollOffset), // scroll offset in pixels (per-pixel)
|
||||||
state.SelectedIndex,
|
state.SelectedIndex,
|
||||||
[]ui.Interaction{{Gesture: ui.Scroll, Handler: scrollHandler}},
|
[]ui.Interaction{
|
||||||
|
{Gesture: ui.Scroll, Handler: scrollHandler},
|
||||||
|
},
|
||||||
|
tapHandler,
|
||||||
)
|
)
|
||||||
|
|
||||||
elems := []ui.Element{headerBar, searchBar}
|
elems := []ui.Element{headerBar, searchBar}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ func findListView(elements []ui.Element) (ui.ListView, bool) {
|
||||||
func TestBrowserLayout_ElementCount(t *testing.T) {
|
func TestBrowserLayout_ElementCount(t *testing.T) {
|
||||||
s := makeTestState(t, 200, 0, 20)
|
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
|
// Should produce: header + search bar + list view = 3 elements
|
||||||
if len(elements) < 3 {
|
if len(elements) < 3 {
|
||||||
|
|
@ -76,7 +76,7 @@ func TestBrowserLayout_ElementCount(t *testing.T) {
|
||||||
func TestBrowserLayout_HeaderRegion(t *testing.T) {
|
func TestBrowserLayout_HeaderRegion(t *testing.T) {
|
||||||
s := makeTestState(t, 200, 0, 20)
|
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
|
// First element should be the header container
|
||||||
if len(elements) == 0 {
|
if len(elements) == 0 {
|
||||||
|
|
@ -102,7 +102,7 @@ func TestBrowserLayout_HeaderRegion(t *testing.T) {
|
||||||
func TestBrowserLayout_ListRegion(t *testing.T) {
|
func TestBrowserLayout_ListRegion(t *testing.T) {
|
||||||
s := makeTestState(t, 200, 0, 20)
|
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)
|
listView, found := findListView(elements)
|
||||||
if !found {
|
if !found {
|
||||||
|
|
@ -127,7 +127,7 @@ func TestBrowserLayout_ListRegion(t *testing.T) {
|
||||||
func TestBrowserLayout_VisibleEntriesCount(t *testing.T) {
|
func TestBrowserLayout_VisibleEntriesCount(t *testing.T) {
|
||||||
s := makeTestState(t, 500, 100, 20)
|
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)
|
listView, found := findListView(elements)
|
||||||
if !found {
|
if !found {
|
||||||
|
|
@ -149,7 +149,7 @@ func TestBrowserLayout_EmptyDirectory(t *testing.T) {
|
||||||
s.CurrentPath = "/empty/dir"
|
s.CurrentPath = "/empty/dir"
|
||||||
s.TotalEntries = 0
|
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 {
|
if len(elements) < 3 {
|
||||||
t.Errorf("expected at least 3 elements for empty dir, got %d", len(elements))
|
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)
|
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)
|
listView, found := findListView(elements)
|
||||||
if !found {
|
if !found {
|
||||||
|
|
@ -214,7 +214,7 @@ func TestBrowserLayout_PartialPage(t *testing.T) {
|
||||||
s.Pages[0] = NewPage(0, entries[:100])
|
s.Pages[0] = NewPage(0, entries[:100])
|
||||||
s.Pages[1] = NewPage(1, 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)
|
listView, found := findListView(elements)
|
||||||
if !found {
|
if !found {
|
||||||
|
|
@ -241,7 +241,7 @@ func TestBrowserLayout_UnloadedPage(t *testing.T) {
|
||||||
entries := make([]Entry, 100)
|
entries := make([]Entry, 100)
|
||||||
s.Pages[0] = NewPage(0, entries)
|
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
|
// Layout should not crash with unloaded pages
|
||||||
if len(elements) < 3 {
|
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
|
// Should still produce valid layout
|
||||||
if len(elements) < 3 {
|
if len(elements) < 3 {
|
||||||
|
|
@ -278,7 +278,7 @@ func TestBrowserLayout_ScreenDimensions(t *testing.T) {
|
||||||
// Test with different screen sizes
|
// Test with different screen sizes
|
||||||
for _, w := range []ui.Dp{400, 800, 1200} {
|
for _, w := range []ui.Dp{400, 800, 1200} {
|
||||||
for _, h := range []ui.Dp{600, 1200, 1800} {
|
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 {
|
if len(elements) < 3 {
|
||||||
t.Errorf("layout failed for %dx%d: expected >= 3 elements, got %d", int(w), int(h), len(elements))
|
t.Errorf("layout failed for %dx%d: expected >= 3 elements, got %d", int(w), int(h), len(elements))
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package browser
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"pad/internal/io/pool"
|
"pad/internal/io/pool"
|
||||||
"pad/internal/io/pool/mock"
|
"pad/internal/io/pool/mock"
|
||||||
|
|
@ -94,6 +95,17 @@ func (bm *BrowserManager) handleBuildIndexSuccess(result pool.Result) {
|
||||||
entries := result.Data.([]mock.DirEntry)
|
entries := result.Data.([]mock.DirEntry)
|
||||||
|
|
||||||
var browserEntries []Entry
|
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 {
|
for _, e := range entries {
|
||||||
info, _ := e.Info()
|
info, _ := e.Info()
|
||||||
browserEntries = append(browserEntries, Entry{
|
browserEntries = append(browserEntries, Entry{
|
||||||
|
|
|
||||||
60
internal/browser/navigation_test.go
Normal file
60
internal/browser/navigation_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -142,17 +142,17 @@ func (l *Logic) Run() {
|
||||||
return
|
return
|
||||||
case update := <-l.configChan:
|
case update := <-l.configChan:
|
||||||
update.apply(l.state)
|
update.apply(l.state)
|
||||||
l.frameChan <- l.state.layout()
|
l.frameChan <- l.state.layout(l.browserManager)
|
||||||
case y := <-l.lastLineYChan:
|
case y := <-l.lastLineYChan:
|
||||||
if ui.Dp(y) != l.state.LastLineY {
|
if ui.Dp(y) != l.state.LastLineY {
|
||||||
l.state.LastLineY = ui.Dp(y)
|
l.state.LastLineY = ui.Dp(y)
|
||||||
l.frameChan <- l.state.layout()
|
l.frameChan <- l.state.layout(l.browserManager)
|
||||||
}
|
}
|
||||||
case events := <-l.inputChan:
|
case events := <-l.inputChan:
|
||||||
for _, evt := range events {
|
for _, evt := range events {
|
||||||
evt.Handler(evt.Data)
|
evt.Handler(evt.Data)
|
||||||
}
|
}
|
||||||
l.frameChan <- l.state.layout()
|
l.frameChan <- l.state.layout(l.browserManager)
|
||||||
case query := <-l.searchQueryChan:
|
case query := <-l.searchQueryChan:
|
||||||
if query != l.state.Browser.Query {
|
if query != l.state.Browser.Query {
|
||||||
l.state.Browser.Query = query
|
l.state.Browser.Query = query
|
||||||
|
|
@ -160,11 +160,11 @@ func (l *Logic) Run() {
|
||||||
browser.HandleSearch(&l.state.Browser, query)
|
browser.HandleSearch(&l.state.Browser, query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
l.frameChan <- l.state.layout()
|
l.frameChan <- l.state.layout(l.browserManager)
|
||||||
case res := <-l.workerPool.ResultChan():
|
case res := <-l.workerPool.ResultChan():
|
||||||
l.handleWorkerResult(res)
|
l.handleWorkerResult(res)
|
||||||
case <-l.resultChan:
|
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() {
|
if res.IsBrowserResult() {
|
||||||
l.browserManager.HandleResult(res)
|
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.
|
// applyBuildIndexResult applies a completed BuildIndexTask result to browser state.
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ func (s *State) Scale() float32 {
|
||||||
// and computes the element tree. Called only when a frame is needed.
|
// and computes the element tree. Called only when a frame is needed.
|
||||||
// Search query sync is handled by the logic goroutine via searchQueryChan,
|
// Search query sync is handled by the logic goroutine via searchQueryChan,
|
||||||
// not here, to ensure proper channel-based state flow.
|
// 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)
|
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
|
||||||
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
|
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
|
||||||
|
|
||||||
|
|
@ -114,7 +114,12 @@ func (s *State) layout() []ui.Element {
|
||||||
|
|
||||||
switch s.page {
|
switch s.page {
|
||||||
case BrowserPage:
|
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:
|
case EditorPage:
|
||||||
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
|
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ type ListView struct {
|
||||||
Items []ListItem
|
Items []ListItem
|
||||||
ScrollOffset Dp // pixel-level scroll offset
|
ScrollOffset Dp // pixel-level scroll offset
|
||||||
Selected int
|
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 }
|
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
|
// Register click area for this row
|
||||||
rowID := fmt.Sprintf("list_row_%d", rowGlobalIndex)
|
rowID := fmt.Sprintf("list_row_%d", rowGlobalIndex)
|
||||||
var filename string
|
|
||||||
if i < len(lv.RowFilenames) {
|
|
||||||
filename = lv.RowFilenames[i]
|
|
||||||
}
|
|
||||||
r.RegisterClick(gtx, rowID, Region{
|
r.RegisterClick(gtx, rowID, Region{
|
||||||
X: lv.region.X, Y: y,
|
X: lv.region.X, Y: y,
|
||||||
W: lv.region.W, H: rowHeight,
|
W: lv.region.W, H: rowHeight,
|
||||||
}, func(data any) {
|
}, func(data any) {
|
||||||
OpenFile(filename)
|
if lv.RowTapHandler != nil {
|
||||||
|
lv.RowTapHandler(rowGlobalIndex)
|
||||||
|
} else {
|
||||||
|
OpenFile(item.Text)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
// Draw main text
|
// Draw main text
|
||||||
textRegion := Region{
|
textRegion := Region{
|
||||||
|
|
@ -331,7 +331,7 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewListView creates a visible ListView element.
|
// 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{
|
return ListView{
|
||||||
id: id,
|
id: id,
|
||||||
region: region,
|
region: region,
|
||||||
|
|
@ -340,6 +340,7 @@ func NewListView(id string, items []ListItem, region Region, scrollOffset Dp, se
|
||||||
Items: items,
|
Items: items,
|
||||||
ScrollOffset: scrollOffset,
|
ScrollOffset: scrollOffset,
|
||||||
Selected: selected,
|
Selected: selected,
|
||||||
|
RowTapHandler: rowTapHandler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user