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:
parent
def4cec498
commit
7240b62a61
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,3 +12,4 @@ var (
|
|||
|
||||
func handleEvent(e event.Event) { }
|
||||
|
||||
func OpenFile(path string) { }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
229
internal/editor/scroll_fix_test.go
Normal file
229
internal/editor/scroll_fix_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user