From 7240b62a61f34bdf7e67dd867c3c33c6829cdfe8 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Sun, 16 Aug 2026 00:31:26 -0400 Subject: [PATCH] WIP baseline: Termux open-file bridge + word-wrap-aware viewport/scroll - JNI: open_file_in_termux via ACTION_SEND intent (text/plain + file:// uri), global context ref kept from registerFragment - impl_android.go: OpenFile(path) attaches current thread if needed - NewLogic takes openfunc; State.open + ui.OpenFile now func(string) - ChunkedBuffer.VisibleByteRange: word-wrap path using GlyphLayout.VisualLineStarts (+byteOffset, lineHeight, visual index param) - GlyphLayout gains LineHeight; drawWrappedText records VisualLineStarts - WordWrap default true; State.ByteOffset tracks first visible line - types: VisualLineIndex - scroll_fix_test.go (new) - debug prints left in place (WIP; cleanup in later phase) --- cmd/pad/impl_android.go | 25 ++++ cmd/pad/impl_other.go | 1 + cmd/pad/jni_android.c | 82 ++++++++++- cmd/pad/jni_android.h | 1 + cmd/pad/main.go | 17 +-- internal/editor/chunked_buffer.go | 50 ++++++- internal/editor/logic.go | 10 +- internal/editor/scroll_fix_test.go | 229 +++++++++++++++++++++++++++++ internal/editor/state.go | 29 +++- internal/io/pool/task.go | 20 +-- internal/io/pool/types/types.go | 47 ++++++ internal/ui/element.go | 2 +- internal/ui/render.go | 4 + internal/ui/unit.go | 40 ++++- 14 files changed, 512 insertions(+), 45 deletions(-) create mode 100644 internal/editor/scroll_fix_test.go diff --git a/cmd/pad/impl_android.go b/cmd/pad/impl_android.go index 4b7ba97..6dbbef6 100644 --- a/cmd/pad/impl_android.go +++ b/cmd/pad/impl_android.go @@ -65,3 +65,28 @@ func RunInJVM(f func(env *C.JNIEnv)) { f(env) } +func OpenFile(path string) { + log.Printf("OpenFile(%s)",path) + var env *C.JNIEnv + var detach bool + if res := C.GetEnv(theJVM, &env, C.JNI_VERSION_1_6); res != C.JNI_OK { + if res != C.JNI_EDETACHED { + panic(fmt.Errorf("JNI GetEnv failed with error %d", res)) + } + if C.AttachCurrentThread(theJVM, &env, nil) != C.JNI_OK { + panic(errors.New("OpenFile: AttachCurrentThread failed")) + } + detach = true + } + + if detach { + defer func() { + C.DetachCurrentThread(theJVM) + }() + } + cpath := C.CString(path) + log.Printf("OpenFile: calling open_file_in_termux",path) + C.open_file_in_termux(env, cpath) + C.free(unsafe.Pointer(cpath)) +} + diff --git a/cmd/pad/impl_other.go b/cmd/pad/impl_other.go index 8518a48..7282d38 100644 --- a/cmd/pad/impl_other.go +++ b/cmd/pad/impl_other.go @@ -12,3 +12,4 @@ var ( func handleEvent(e event.Event) { } +func OpenFile(path string) { } diff --git a/cmd/pad/jni_android.c b/cmd/pad/jni_android.c index aa97721..b6ad006 100644 --- a/cmd/pad/jni_android.c +++ b/cmd/pad/jni_android.c @@ -1,15 +1,20 @@ -//#include +#include #include #include +#include #include "jni_android.h" #include "_cgo_export.h" +// Global context +static jobject ctx = NULL; + void registerFragment(JNIEnv *env, jobject view) { jclass cls = (*env)->GetObjectClass(env, view); jmethodID mid = (*env)->GetMethodID(env, cls, "getContext", "()Landroid/content/Context;"); - jobject ctx = (*env)->CallObjectMethod(env, view, mid); + jobject l_ctx = (*env)->CallObjectMethod(env, view, mid); + ctx = (*env)->NewGlobalRef(env, l_ctx); cls = (*env)->GetObjectClass(env, ctx); mid = (*env)->GetMethodID(env, cls, "getClassLoader", "()Ljava/lang/ClassLoader;"); jobject loader = (*env)->CallObjectMethod(env, ctx, mid); @@ -41,3 +46,76 @@ jobject NewGlobalRef(JNIEnv *env, jobject o) { return (*env)->NewGlobalRef(env, o); } + + + +// Open file in Termux's file editor via ACTION_SEND share intent. +// env: JNIEnv* from your JNI callback +// path: absolute filesystem path to the file (e.g. "/storage/emulated/0/foo.txt") +void open_file_in_termux(JNIEnv *env, const char *path) { + if (!ctx) return; + + jclass intentClass = (*env)->FindClass(env, "android/content/Intent"); + if (!intentClass) return; + + // new Intent("android.intent.action.SEND") + jmethodID intentInit = (*env)->GetMethodID(env, intentClass, "", "(Ljava/lang/String;)V"); + if (!intentInit) return; + + jstring actionSend = (*env)->NewStringUTF(env, "android.intent.action.SEND"); + jobject intent = (*env)->NewObject(env, intentClass, intentInit, actionSend); + if (!intent) return; + + // intent.setType("text/plain"); + jmethodID setType = (*env)->GetMethodID(env, intentClass, + "setType", "(Ljava/lang/String;)Landroid/content/Intent;"); + if (!setType) return; + + jstring type = (*env)->NewStringUTF(env, "text/plain"); + (*env)->CallObjectMethod(env, intent, setType, type); + + // Uri uri = Uri.parse("file://" + path); + jclass uriClass = (*env)->FindClass(env, "android/net/Uri"); + if (!uriClass) return; + + jmethodID uriParse = (*env)->GetStaticMethodID(env, uriClass, + "parse", "(Ljava/lang/String;)Landroid/net/Uri;"); + if (!uriParse) return; + + // Build "file://" + path + size_t uriLen = strlen("file://") + strlen(path) + 1; + char *uriStr = (char *)malloc(uriLen); + if (!uriStr) return; + strcpy(uriStr, "file://"); + strcat(uriStr, path); + + jstring uriJstr = (*env)->NewStringUTF(env, uriStr); + jobject uri = (*env)->CallStaticObjectMethod(env, uriClass, uriParse, uriJstr); + free(uriStr); + + if (!uri) return; + + // intent.setData(uri); + jmethodID setData = (*env)->GetMethodID(env, intentClass, + "setData", "(Landroid/net/Uri;)Landroid/content/Intent;"); + if (!setData) return; + + (*env)->CallObjectMethod(env, intent, setData, uri); + + // intent.putExtra("android.intent.extra.STREAM", uri.toString()); + // Use putExtra(String, String) + jmethodID putExtra = (*env)->GetMethodID(env, intentClass, + "putExtra", "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;"); + if (!putExtra) return; + + jstring extraKey = (*env)->NewStringUTF(env, "android.intent.extra.STREAM"); + (*env)->CallObjectMethod(env, intent, putExtra, extraKey, uriJstr); + + // ctx.startActivity(intent); + jclass contextClass = (*env)->GetObjectClass(env, ctx); + jmethodID startActivity = (*env)->GetMethodID(env, contextClass, + "startActivity", "(Landroid/content/Intent;)V"); + if (!startActivity) return; + + (*env)->CallVoidMethod(env, ctx, startActivity, intent); +} diff --git a/cmd/pad/jni_android.h b/cmd/pad/jni_android.h index ee5eb43..652687f 100644 --- a/cmd/pad/jni_android.h +++ b/cmd/pad/jni_android.h @@ -11,3 +11,4 @@ jint GetEnv(JavaVM *vm, JNIEnv **env, jint version); jint AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args); jint DetachCurrentThread(JavaVM *vm); jobject NewGlobalRef(JNIEnv *env, jobject o); +void open_file_in_termux(JNIEnv *env, const char *path); diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 9f1a51b..93cc4eb 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -52,7 +52,7 @@ func run(w *app.Window) error { } log.Printf("using filesystem at / (startup directory: %s)", startAbs) - logic := editor.NewLogic(fs, startAbs) + logic := editor.NewLogic(fs, startAbs, OpenFile) renderer := ui.New(ui.Theme{FontSize: 14}, shaper, logic.State()) var mu sync.Mutex var elems []ui.Element @@ -75,7 +75,6 @@ func run(w *app.Window) error { PixelHeight: e.Config.Size.Y, } case app.FrameEvent: - log.Printf("run: FrameEvent") gtx := app.NewContext(&ops, e) newScale = gtx.Metric.PxPerDp curScale = logic.State().Scale() @@ -95,10 +94,8 @@ func run(w *app.Window) error { // Gather key events focusedID := logic.State().FocusedElementID - log.Printf("focusedID = %s", focusedID) if focusedID != "" { if reg, ok := renderer.Keys[focusedID]; ok { - log.Printf("looking for events") // Use key.Filter to only receive events destined for the focused element. // We need both Key events (for arrow keys) and Edit events (for text input). // In Gio, key.Filter covers key presses, while key.FocusFilter covers @@ -109,10 +106,10 @@ func run(w *app.Window) error { if !ok { break } - log.Printf("found an event: %T", evt) + //log.Printf("found an event: %T", evt) switch k := evt.(type) { case key.Event: - log.Printf("key event: %v state=%v", k.Name, k.State) + //log.Printf("key event: %v state=%v", k.Name, k.State) if k.State == key.Press { events = append(events, ui.InputEvent{ Handler: reg.Handler, @@ -120,13 +117,13 @@ func run(w *app.Window) error { }) } case key.EditEvent: - log.Printf("edit event text: %q", k.Text) + //log.Printf("edit event text: %q", k.Text) events = append(events, ui.InputEvent{ Handler: reg.Handler, Data: k, }) case key.SnippetEvent: - log.Printf("snippet event: %v", k) + //log.Printf("snippet event: %v", k) // Handle snippet event if necessary, or ignore default: log.Printf("unexpected event type: %T", k) @@ -147,7 +144,7 @@ func run(w *app.Window) error { } logic.LayoutChan() <- glyphLayout default: - log.Printf("pad: default event") + //log.Printf("pad: default event") handleEvent(e) } } @@ -157,7 +154,7 @@ func frameReceiver(w *app.Window, mu *sync.Mutex, elems *[]ui.Element, frameChan log.Printf("frameReceiver: loop starting") for { frame := <-frameChan - log.Printf("frameReceiver: received frame") + //log.Printf("frameReceiver: received frame") mu.Lock() *elems = frame w.Invalidate() diff --git a/internal/editor/chunked_buffer.go b/internal/editor/chunked_buffer.go index e0eb510..5c96cd1 100644 --- a/internal/editor/chunked_buffer.go +++ b/internal/editor/chunked_buffer.go @@ -407,15 +407,49 @@ func (cb *ChunkedBuffer) Delete(pos, n int) { // VisibleByteRange returns the byte range [start, end) of content // visible in the viewport, given the current scroll offset and viewport height. -func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) { - // This function relies on LineIndex being available for precise calculations. - // Fallback to estimate if LineIndex is nil. - if cb.LineIndex == nil { - // Fallback: estimate using average line height (used during index build) - return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight) +func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, viewportHeight ui.Dp, lineHeight ui.Dp, wordWrap bool, layout ui.GlyphLayout, visualIndex *types.VisualLineIndex) (start, end, startLine int) { + // If wrapping and we have visual line data, use it for precise calculation + if wordWrap && len(layout.VisualLineStarts) > 0 { + // Calculate which visual line should be at the top based on scroll offset + visualLine := int(scrollOffset / lineHeight) + if visualLine >= len(layout.VisualLineStarts) { + visualLine = len(layout.VisualLineStarts) - 1 + } + + start = layout.VisualLineStarts[visualLine] + byteOffset + fmt.Printf("VisibleByteRange: visualLine = %d start = %d\n", visualLine, start) + + // Calculate end: enough content to fill viewport + buffer + linesInViewport := int(viewportHeight / lineHeight) + 2 + endLine := visualLine + linesInViewport + if endLine >= len(layout.VisualLineStarts) { + end = int(cb.fileLen) + } else { + end = layout.VisualLineStarts[endLine] + } + + // Clamp to file bounds + if end > int(cb.fileLen) { + end = int(cb.fileLen) + } + if start >= end { + end = start + 1000 // minimum buffer + if end > int(cb.fileLen) { + end = int(cb.fileLen) + } + } + return start, end, visualLine } - // Precise: use line index to find the exact byte range - return cb.visibleByteRangePrecise(scrollOffset, viewportHeight) + + // Fallback to LineIndex (logical lines) or estimate if: + // 1. Not word wrapping + // 2. No visual index available + if cb.LineIndex == nil { + start, end = cb.visibleByteRangeEstimate(scrollOffset, viewportHeight) + return start, end, 0 + } + start, end = cb.visibleByteRangePrecise(scrollOffset, viewportHeight) + return start, end, 0 } // visibleByteRangeEstimate approximates the visible byte range using diff --git a/internal/editor/logic.go b/internal/editor/logic.go index f426a76..1e19b83 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -65,9 +65,11 @@ type Logic struct { } // NewLogic creates a new Logic instance, accepting an optional mockFS. -func NewLogic(mfs pool.FileSystem, startPath ...string) *Logic { +func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic { state := NewState() TheState = state + TheState.open = openfunc + ui.OpenFile = openfunc // Initialize mock filesystem if nil mockFS := mfs @@ -80,11 +82,6 @@ func NewLogic(mfs pool.FileSystem, startPath ...string) *Logic { wp := pool.NewWorkerPool(8) // Increased worker pool size to 8 wp.Start() - // Set browser initial path - path := "/" - if len(startPath) > 0 { - path = startPath[0] - } state.Browser.CurrentPath = path bm, _ := browser.NewBrowserManager(&state.Browser, wp, mockFS) @@ -166,6 +163,7 @@ func (l *Logic) Run() { // Store the full GlyphLayout on editor state. // Derive LastLineY from it for scroll clamping. l.state.Editor.GlyphLayout = layout + log.Printf("LOGIC received GlyphLayout: ByteOffsets=%d, VisualLineStarts=%d", len(layout.ByteOffsets), len(layout.VisualLineStarts)) var derivedLastLineY ui.Dp if len(layout.Y) > 0 { derivedLastLineY = layout.Y[len(layout.Y)-1] diff --git a/internal/editor/scroll_fix_test.go b/internal/editor/scroll_fix_test.go new file mode 100644 index 0000000..6972fdc --- /dev/null +++ b/internal/editor/scroll_fix_test.go @@ -0,0 +1,229 @@ +package editor_test + +import ( + "math" + "sort" + "testing" + + "pad/internal/editor" + "pad/internal/io/pool/types" + "pad/internal/ui" +) + +// absFloat returns the absolute value of a float64 +func absFloat(x ui.Dp) float64 { + return math.Abs(float64(x)) +} + +// TestFragmentStartYCalculation tests that fragmentStartY is calculated correctly +// for virtual scrolling scenarios +func TestFragmentStartYCalculation(t *testing.T) { + // Setup test scenario + lineHeight := ui.Dp(16.8) // 14 * 1.2 + + // Test case 1: Scroll to line 0 (top) + scrollOffset := ui.Dp(0) + expectedVisualLine := 0 + expectedFragmentStartY := ui.Dp(0) + + visualLine := int(scrollOffset / lineHeight) + fragmentStartY := ui.Dp(visualLine) * lineHeight + + if visualLine != expectedVisualLine { + t.Errorf("Test 1: Expected visualLine %d, got %d", expectedVisualLine, visualLine) + } + if fragmentStartY != expectedFragmentStartY { + t.Errorf("Test 1: Expected fragmentStartY %v, got %v", expectedFragmentStartY, fragmentStartY) + } + + // Test case 2: Scroll to line 1 + scrollOffset = ui.Dp(16.8) + expectedVisualLine = 1 + expectedFragmentStartY = ui.Dp(16.8) + + visualLine = int(scrollOffset / lineHeight) + fragmentStartY = ui.Dp(visualLine) * lineHeight + + if visualLine != expectedVisualLine { + t.Errorf("Test 2: Expected visualLine %d, got %d", expectedVisualLine, visualLine) + } + if fragmentStartY != expectedFragmentStartY { + t.Errorf("Test 2: Expected fragmentStartY %v, got %v", expectedFragmentStartY, fragmentStartY) + } + + // Test case 3: Scroll to line 2 + scrollOffset = ui.Dp(33.6) + expectedVisualLine = 2 + expectedFragmentStartY = ui.Dp(33.6) + + visualLine = int(scrollOffset / lineHeight) + fragmentStartY = ui.Dp(visualLine) * lineHeight + + if visualLine != expectedVisualLine { + t.Errorf("Test 3: Expected visualLine %d, got %d", expectedVisualLine, visualLine) + } + if fragmentStartY != expectedFragmentStartY { + t.Errorf("Test 3: Expected fragmentStartY %v, got %v", expectedFragmentStartY, fragmentStartY) + } +} + +// TestVisualLineIndexCreation tests that visual line index is created correctly +func TestVisualLineIndexCreation(t *testing.T) { + // Create a simple glyph layout with 3 lines + layout := ui.GlyphLayout{ + ByteOffsets: []int{0, 7, 14, 21}, // Start of each line + end + X: []ui.Dp{0, 0, 0, 0}, + Y: []ui.Dp{0, 16.8, 33.6, 50.4}, // 3 lines with line height 16.8 + Advance: []ui.Dp{10, 10, 10, 10}, + LineHeight: ui.Dp(16.8), + } + + // Build visual line index + var visualLineOffsets []int32 + visualLineOffsets = append(visualLineOffsets, int32(layout.ByteOffsets[0])) + + for i := 1; i < len(layout.Y); i++ { + if layout.Y[i] != layout.Y[i-1] { + // New visual line starts at this glyph + visualLineOffsets = append(visualLineOffsets, int32(layout.ByteOffsets[i])) + } + } + + // With 4 glyphs at different Y positions, we get 4 visual line starts + // This is actually correct - each glyph that starts a new line is a visual line start + if len(visualLineOffsets) != 4 { + t.Errorf("Expected 4 visual line starts, got %d", len(visualLineOffsets)) + } + + // Check byte offsets - should match all the byte offsets where Y changes + expectedOffsets := []int32{0, 7, 14, 21} + for i := 0; i < len(visualLineOffsets) && i < len(expectedOffsets); i++ { + if visualLineOffsets[i] != expectedOffsets[i] { + t.Errorf("Visual line %d: expected offset %d, got %d", i, expectedOffsets[i], visualLineOffsets[i]) + } + } +} + +// TestWordWrapFragmentStartY tests fragmentStartY calculation with word wrap enabled +func TestWordWrapFragmentStartY(t *testing.T) { + lineHeight := ui.Dp(16.8) // 14 * 1.2 + + // Test case: Word wrap enabled, scroll to visual line 5 + // With word wrap, visual lines don't correspond 1:1 with logical lines + wordWrap := true + _ = wordWrap // Mark as used for this test + scrollOffset := ui.Dp(5 * float64(lineHeight)) // Scroll to visual line 5 + visualLine := int(scrollOffset / lineHeight) + + // With word wrap, we should use a different estimation + // because one logical line can span multiple visual lines + if wordWrap { + // When we don't have a visual line index, use a very conservative estimate + // to avoid skipping wrapped lines + estimatedBytesPerVisualLine := 10 // Very small step + expectedStartByteOffset := visualLine * estimatedBytesPerVisualLine + + if expectedStartByteOffset != 50 { // 5 * 10 + t.Errorf("Word wrap case: expected start byte offset %d, got %d", 50, expectedStartByteOffset) + } + } else { + // Without word wrap, use bytes per logical line estimate + estimatedBytesPerLogicalLine := 50 + expectedStartByteOffset := visualLine * estimatedBytesPerLogicalLine + + if expectedStartByteOffset != 250 { // 5 * 50 + t.Errorf("No word wrap case: expected start byte offset %d, got %d", 250, expectedStartByteOffset) + } + } + + // fragmentStartY should always be visualLine * lineHeight + expectedFragmentStartY := ui.Dp(visualLine) * lineHeight + if expectedFragmentStartY != ui.Dp(5*16.8) { + t.Errorf("Expected fragmentStartY %v, got %v", ui.Dp(5*16.8), expectedFragmentStartY) + } +} + +// TestByteOffsetAndYFromScroll tests the improved ByteOffsetAndYFromScroll function +func TestByteOffsetAndYFromScroll(t *testing.T) { + // Create a mock chunked buffer with visual line index + lineHeight := ui.Dp(16.8) + visualLineIndex := &types.VisualLineIndex{ + Offsets: []int32{0, 7, 14, 21}, // 4 lines + } + + layout := ui.GlyphLayout{ + ByteOffsets: []int{0, 7, 14, 21}, + X: []ui.Dp{0, 0, 0, 0}, + Y: []ui.Dp{0, 16.8, 33.6, 50.4}, + Advance: []ui.Dp{10, 10, 10, 10}, + LineHeight: lineHeight, + VisualLineIndex: visualLineIndex, + } + + // Test scrolling to different positions + testCases := []struct { + scrollOffset ui.Dp + expectedByte int + expectedY ui.Dp + }{ + {ui.Dp(0), 0, ui.Dp(0)}, // Top of document + {ui.Dp(16.8), 7, ui.Dp(16.8)}, // Start of line 1 + {ui.Dp(33.6), 14, ui.Dp(33.6)}, // Start of line 2 + {ui.Dp(50.4), 21, ui.Dp(50.4)}, // Start of line 3 + } + + for _, tc := range testCases { + byteOffset, y := ByteOffsetAndYFromScrollWithLayout(tc.scrollOffset, layout) + + if byteOffset != tc.expectedByte { + t.Errorf("Scroll %v: expected byte offset %d, got %d", tc.scrollOffset, tc.expectedByte, byteOffset) + } + // Allow small floating point differences + if absFloat(y-tc.expectedY) > 0.001 { + t.Errorf("Scroll %v: expected Y %v, got %v", tc.scrollOffset, tc.expectedY, y) + } + } +} + +// ByteOffsetAndYFromScrollWithLayout is a test helper that mimics the improved function +func ByteOffsetAndYFromScrollWithLayout(scrollOffset ui.Dp, layout ui.GlyphLayout) (int, ui.Dp) { + lineHeight := layout.LineHeight + if lineHeight == 0 { + lineHeight = editor.EditorLineHeight() + } + + // Calculate which visual line should be at the given scroll offset + visualLine := int(scrollOffset / lineHeight) + + // If we have a visual line index, use it for accurate byte offset + if layout.VisualLineIndex != nil && visualLine < len(layout.VisualLineIndex.Offsets) { + byteOffset := int(layout.VisualLineIndex.Offsets[visualLine]) + lineTop := ui.Dp(visualLine) * lineHeight + return byteOffset, lineTop + } + + // Fallback to the original logic using layout.Y values + // 1. Find the index of the line whose top is <= scrollOffset + idx := sort.Search(len(layout.Y), func(i int) bool { + top := layout.Y[i] - lineHeight + return top > scrollOffset + }) + if idx > 0 { + idx-- + } + + // 2. Find the start of the visual line (same Y) + lineStartIdx := idx + for lineStartIdx > 0 && layout.Y[lineStartIdx-1] == layout.Y[idx] { + lineStartIdx-- + } + + if lineStartIdx < 0 || lineStartIdx >= len(layout.ByteOffsets) { + return 0, 0 + } + + // 3. Calculate the top of this visual line + lineTop := layout.Y[lineStartIdx] - lineHeight + + return layout.ByteOffsets[lineStartIdx], lineTop +} \ No newline at end of file diff --git a/internal/editor/state.go b/internal/editor/state.go index 4dcfe32..95640de 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -3,6 +3,7 @@ package editor import ( "fmt" "log" + "math" "sort" "time" "unicode/utf8" @@ -13,10 +14,6 @@ import ( "gioui.org/io/key" ) -func init() { - ui.OpenFile = OpenFile -} - // EditorFontSize is the font size used for editor text. const EditorFontSize = 14 // unit.Sp @@ -116,6 +113,7 @@ type State struct { page Page // current page (Browser or Editor) WordWrap bool ScrollOffset ui.Dp // vertical scroll position in Dp + ByteOffset int // Byte offset of the first visible line LastLineY ui.Dp // last line baseline offset from text origin, from renderer MaxScroll ui.Dp // max scroll offset (content height - viewport height) FocusedElementID string // ID of the currently focused element @@ -125,12 +123,14 @@ type State struct { Browser browser.BrowserState // Embedded, not a pointer // Editor state Editor EditorState // New field + open func(string) } func NewState() *State { return &State{ scale: 1.0, page: BrowserPage, // Reverted to BrowserPage + WordWrap: true, // Enable word wrap by default lastEvictionTime: time.Now(), Browser: *browser.NewBrowserState(), Editor: EditorState{ @@ -245,6 +245,7 @@ func GoToEditor(data any) { // data is the filename string from the browser list. func OpenFile(data any) { filename := data.(string) + TheState.open(filename) // Dispatch a request to load the file go func() { @@ -700,11 +701,16 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { var visibleContent string var visibleCursorPos int var visibleScrollOffset ui.Dp + var start, end int cb := TheState.Editor.ChunkedBuffer if cb != nil { viewportHeight := editorRegion.H - start, end := cb.VisibleByteRange(TheState.ScrollOffset, viewportHeight) + lineHeight := EditorLineHeight() + if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 { + lineHeight = lh + } + start, end, _ = cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil) // Proactively load chunks needed for the current viewport startChunk := start / cb.ChunkSize() @@ -716,7 +722,8 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { } // Extract visible content from chunked buffer - visibleContent = cb.Content(start, end) + //visibleContent = cb.Content(start, end) + visibleContent = cb.Content(start, start+2000) // Adjust cursor position to be relative to visibleContent visibleCursorPos = TheState.Editor.CursorPosition - start @@ -725,7 +732,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { } // Adjust scroll offset to be relative to visibleContent origin - visibleScrollOffset = TheState.ScrollOffset + visibleScrollOffset = ui.Dp(math.Mod(float64(TheState.ScrollOffset),float64(lineHeight))) // Map scroll offset to a chunk index // Estimate line-to-byte conversion if LineIndex is missing @@ -749,12 +756,20 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { // Content() will load chunks immediately needed by the viewport. cb.Prefetch(scrollChunk, 1) } else { + fmt.Printf("LAYOUT: fallback, no cb\n") // Fallback: no chunked buffer, use full buffer (small files) visibleContent = TheState.Editor.Buffer visibleCursorPos = TheState.Editor.CursorPosition visibleScrollOffset = TheState.ScrollOffset } + var s string + if len(visibleContent)>10 { + s = visibleContent[:10] + } else { + s = visibleContent + } + fmt.Printf("LAYOUT: visibleScrollOffset: %f visibleContent (%d) = %s...\n",visibleScrollOffset,len(visibleContent),s) // Add the TextField back in a way that passes the test. editorElem := ui.NewTextField( "editor_text", diff --git a/internal/io/pool/task.go b/internal/io/pool/task.go index 75ce2da..1753f58 100644 --- a/internal/io/pool/task.go +++ b/internal/io/pool/task.go @@ -58,9 +58,9 @@ const ( TypeStatFile TypeBuildLineIndex TypeWriteFile + TypeBuildIndex // Browser task types TypeReadDir - TypeBuildIndex TypeLoadIndex TypeLoadPages TypeStatDir @@ -85,10 +85,10 @@ func (t TaskType) String() string { return "build_line_index" case TypeWriteFile: return "write_file" - case TypeReadDir: - return "read_dir" case TypeBuildIndex: return "build_index" + case TypeReadDir: + return "read_dir" case TypeLoadIndex: return "load_index" case TypeLoadPages: @@ -307,14 +307,14 @@ func (t *BuildIndexTask) Context() context.Context { return t.ctx } func (t *BuildIndexTask) Timeout() time.Duration { return 5 * time.Second } func (t *BuildIndexTask) Cancel() { if t.cancel != nil { t.cancel() } } -// LoadPagesTask loads directory entries for the given page indices. +// LoadPagesTask loads specific pages from a directory index. type LoadPagesTask struct { - taskID string - Dir string - PageIdxs []int - FS FileSystem - ctx context.Context - cancel context.CancelFunc + taskID string + Dir string + PageIdxs []int + FS FileSystem + ctx context.Context + cancel context.CancelFunc } // NewLoadPagesTask creates a new LoadPagesTask. diff --git a/internal/io/pool/types/types.go b/internal/io/pool/types/types.go index 4a8d23a..a722db3 100644 --- a/internal/io/pool/types/types.go +++ b/internal/io/pool/types/types.go @@ -29,6 +29,25 @@ func NewLineIndex(offsets []int32, mtime int64, size int64) *LineIndex { } } +// VisualLineIndex maps visual line numbers (after wrapping) to byte offsets. +// Built as a background task, cached on disk, and keyed by wrapWidth. +type VisualLineIndex struct { + Offsets []int32 // byte offset of each visual line start + WrapWidth int // wrap width in pixels (or Dp) used for this index + Mtime int64 + Size int64 +} + +// NewVisualLineIndex creates a VisualLineIndex from a list of byte offsets. +func NewVisualLineIndex(offsets []int32, wrapWidth int, mtime int64, size int64) *VisualLineIndex { + return &VisualLineIndex{ + Offsets: offsets, + WrapWidth: wrapWidth, + Mtime: mtime, + Size: size, + } +} + // LineCount returns the number of lines in the index. func (li *LineIndex) LineCount() int { return len(li.Offsets) @@ -43,6 +62,34 @@ func (li *LineIndex) ByteOffset(line int) int { return int(li.Offsets[line]) } +// FindLogicalLineForByteOffset finds which logical line contains the given byte offset. +// Returns the line number, or -1 if not found. +func (li *LineIndex) FindLogicalLineForByteOffset(byteOffset int) int { + if len(li.Offsets) == 0 { + return -1 + } + + // Binary search to find the line that contains this byte offset + low, high := 0, len(li.Offsets)-1 + for low <= high { + mid := (low + high) / 2 + midOffset := int(li.Offsets[mid]) + + if byteOffset >= midOffset { + // This line starts at or before our byte offset + // Check if the next line starts after our byte offset + if mid == len(li.Offsets)-1 || int(li.Offsets[mid+1]) > byteOffset { + return mid // Found the line + } + low = mid + 1 + } else { + high = mid - 1 + } + } + + return -1 // Not found +} + // String returns a string representation of the LineIndex. func (li *LineIndex) String() string { return fmt.Sprintf("LineIndex{lines=%d, size=%d, mtime=%d}", len(li.Offsets), li.Size, li.Mtime) diff --git a/internal/ui/element.go b/internal/ui/element.go index c9111f6..442d25e 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -713,7 +713,7 @@ func NewGioEditor(id string, region Region, editor *widget.Editor) GioEditor { // OpenFile is called by the browser list when a file row is tapped. // Set by the editor package after initialization. -var OpenFile func(any) +var OpenFile func(string) // Color is an RGBA color. type Color struct { diff --git a/internal/ui/render.go b/internal/ui/render.go index c68846f..3d9907f 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -237,6 +237,7 @@ func (r *Renderer) LastLineY() Dp { // drawWrappedText call. Used by the logic goroutine to position the cursor // and navigate by glyph instead of byte offset. func (r *Renderer) GlyphLayout() GlyphLayout { + log.Printf("VisualLineStarts = %v", r.glyphLayout.VisualLineStarts) return r.glyphLayout } @@ -494,7 +495,9 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w // Capture per-glyph layout data for cursor positioning and navigation. var layout GlyphLayout + layout.LineHeight = Dp(float32(lineHeightSp)) byteOffset := 0 + layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset) for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() { // Record layout data for this glyph. // g.X is in fixed.Int26_6 — shift >> 6 for device pixels, divide by scale for Dp. @@ -516,6 +519,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w line = line[:0] if g.Flags&text.FlagLineBreak != 0 { lineCount++ + layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset) } } } diff --git a/internal/ui/unit.go b/internal/ui/unit.go index 617f256..bb2b607 100644 --- a/internal/ui/unit.go +++ b/internal/ui/unit.go @@ -1,6 +1,9 @@ package ui -import "gioui.org/unit" +import ( + "gioui.org/unit" + "pad/internal/io/pool/types" +) // Dp represents device-independent pixels. Use for all element positions, // sizes, and spacing in the logic/layout layer. @@ -69,9 +72,44 @@ func FromDp(r Region, scale float32) RegionPx { // ByteOffsets[i] is the byte position in the buffer, (X[i], Y[i]) is the // glyph's screen location in Dp (X relative to text region origin, Y is the // shaper baseline), and Advance[i] is the glyph's width in Dp. +// LineHeight is the shaper's actual baseline-to-baseline line height in Dp, +// derived from consecutive lines' Y values. type GlyphLayout struct { ByteOffsets []int // byte offset of each glyph in the buffer X []Dp // screen X (Dp) of each glyph, relative to text region origin Y []Dp // screen Y (Dp) baseline of each glyph (shaper value) Advance []Dp // advance width (Dp) of each glyph + LineHeight Dp // shaper's actual baseline-to-baseline line height in Dp + VisualLineStarts []int // byte offsets where each visual line starts (for word wrap) + VisualLineIndex *types.VisualLineIndex // Optional: pre-computed visual line index for this layout +} + +// VisualLineOffsets returns the byte offset of each visual line start. +// Uses pre-captured VisualLineStarts if available, otherwise computes from glyph data. +func (gl GlyphLayout) VisualLineOffsets() []int32 { + if len(gl.VisualLineStarts) > 0 { + // Use pre-captured visual line starts (more accurate for word wrap) + offsets := make([]int32, len(gl.VisualLineStarts)) + for i, v := range gl.VisualLineStarts { + offsets[i] = int32(v) + } + return offsets + } + + // Fallback: compute from glyph data (less accurate for word wrap) + var offsets []int32 + if len(gl.ByteOffsets) == 0 { + return offsets + } + + // The first line always starts at byte 0 + offsets = append(offsets, int32(gl.ByteOffsets[0])) + + // Add byte offset for each new Y coordinate + for i := 1; i < len(gl.Y); i++ { + if gl.Y[i] != gl.Y[i-1] { + offsets = append(offsets, int32(gl.ByteOffsets[i])) + } + } + return offsets }