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)
This commit is contained in:
Greg Pomerantz 2026-08-16 00:31:26 -04:00
parent def4cec498
commit 7240b62a61
14 changed files with 512 additions and 45 deletions

View File

@ -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))
}

View File

@ -12,3 +12,4 @@ var (
func handleEvent(e event.Event) { }
func OpenFile(path string) { }

View File

@ -1,15 +1,20 @@
//#include <stdlib.h>
#include <stdlib.h>
#include <stdio.h>
#include <jni.h>
#include <string.h>
#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, "<init>", "(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);
}

View File

@ -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);

View File

@ -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()

View File

@ -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
}
// Precise: use line index to find the exact byte range
return cb.visibleByteRangePrecise(scrollOffset, viewportHeight)
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
}
// 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

View File

@ -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]

View File

@ -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
}

View File

@ -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",

View File

@ -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,7 +307,7 @@ 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

View File

@ -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)

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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
}