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)
|
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 handleEvent(e event.Event) { }
|
||||||
|
|
||||||
|
func OpenFile(path string) { }
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
|
|
||||||
//#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
|
#include <string.h>
|
||||||
#include "jni_android.h"
|
#include "jni_android.h"
|
||||||
#include "_cgo_export.h"
|
#include "_cgo_export.h"
|
||||||
|
|
||||||
|
// Global context
|
||||||
|
static jobject ctx = NULL;
|
||||||
|
|
||||||
void
|
void
|
||||||
registerFragment(JNIEnv *env, jobject view) {
|
registerFragment(JNIEnv *env, jobject view) {
|
||||||
jclass cls = (*env)->GetObjectClass(env, view);
|
jclass cls = (*env)->GetObjectClass(env, view);
|
||||||
jmethodID mid = (*env)->GetMethodID(env, cls, "getContext", "()Landroid/content/Context;");
|
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);
|
cls = (*env)->GetObjectClass(env, ctx);
|
||||||
mid = (*env)->GetMethodID(env, cls, "getClassLoader", "()Ljava/lang/ClassLoader;");
|
mid = (*env)->GetMethodID(env, cls, "getClassLoader", "()Ljava/lang/ClassLoader;");
|
||||||
jobject loader = (*env)->CallObjectMethod(env, ctx, mid);
|
jobject loader = (*env)->CallObjectMethod(env, ctx, mid);
|
||||||
|
|
@ -41,3 +46,76 @@ jobject
|
||||||
NewGlobalRef(JNIEnv *env, jobject o) {
|
NewGlobalRef(JNIEnv *env, jobject o) {
|
||||||
return (*env)->NewGlobalRef(env, 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 AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args);
|
||||||
jint DetachCurrentThread(JavaVM *vm);
|
jint DetachCurrentThread(JavaVM *vm);
|
||||||
jobject NewGlobalRef(JNIEnv *env, jobject o);
|
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)
|
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())
|
renderer := ui.New(ui.Theme{FontSize: 14}, shaper, logic.State())
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var elems []ui.Element
|
var elems []ui.Element
|
||||||
|
|
@ -75,7 +75,6 @@ func run(w *app.Window) error {
|
||||||
PixelHeight: e.Config.Size.Y,
|
PixelHeight: e.Config.Size.Y,
|
||||||
}
|
}
|
||||||
case app.FrameEvent:
|
case app.FrameEvent:
|
||||||
log.Printf("run: FrameEvent")
|
|
||||||
gtx := app.NewContext(&ops, e)
|
gtx := app.NewContext(&ops, e)
|
||||||
newScale = gtx.Metric.PxPerDp
|
newScale = gtx.Metric.PxPerDp
|
||||||
curScale = logic.State().Scale()
|
curScale = logic.State().Scale()
|
||||||
|
|
@ -95,10 +94,8 @@ func run(w *app.Window) error {
|
||||||
|
|
||||||
// Gather key events
|
// Gather key events
|
||||||
focusedID := logic.State().FocusedElementID
|
focusedID := logic.State().FocusedElementID
|
||||||
log.Printf("focusedID = %s", focusedID)
|
|
||||||
if focusedID != "" {
|
if focusedID != "" {
|
||||||
if reg, ok := renderer.Keys[focusedID]; ok {
|
if reg, ok := renderer.Keys[focusedID]; ok {
|
||||||
log.Printf("looking for events")
|
|
||||||
// Use key.Filter to only receive events destined for the focused element.
|
// 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).
|
// 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
|
// In Gio, key.Filter covers key presses, while key.FocusFilter covers
|
||||||
|
|
@ -109,10 +106,10 @@ func run(w *app.Window) error {
|
||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
log.Printf("found an event: %T", evt)
|
//log.Printf("found an event: %T", evt)
|
||||||
switch k := evt.(type) {
|
switch k := evt.(type) {
|
||||||
case key.Event:
|
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 {
|
if k.State == key.Press {
|
||||||
events = append(events, ui.InputEvent{
|
events = append(events, ui.InputEvent{
|
||||||
Handler: reg.Handler,
|
Handler: reg.Handler,
|
||||||
|
|
@ -120,13 +117,13 @@ func run(w *app.Window) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case key.EditEvent:
|
case key.EditEvent:
|
||||||
log.Printf("edit event text: %q", k.Text)
|
//log.Printf("edit event text: %q", k.Text)
|
||||||
events = append(events, ui.InputEvent{
|
events = append(events, ui.InputEvent{
|
||||||
Handler: reg.Handler,
|
Handler: reg.Handler,
|
||||||
Data: k,
|
Data: k,
|
||||||
})
|
})
|
||||||
case key.SnippetEvent:
|
case key.SnippetEvent:
|
||||||
log.Printf("snippet event: %v", k)
|
//log.Printf("snippet event: %v", k)
|
||||||
// Handle snippet event if necessary, or ignore
|
// Handle snippet event if necessary, or ignore
|
||||||
default:
|
default:
|
||||||
log.Printf("unexpected event type: %T", k)
|
log.Printf("unexpected event type: %T", k)
|
||||||
|
|
@ -147,7 +144,7 @@ func run(w *app.Window) error {
|
||||||
}
|
}
|
||||||
logic.LayoutChan() <- glyphLayout
|
logic.LayoutChan() <- glyphLayout
|
||||||
default:
|
default:
|
||||||
log.Printf("pad: default event")
|
//log.Printf("pad: default event")
|
||||||
handleEvent(e)
|
handleEvent(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +154,7 @@ func frameReceiver(w *app.Window, mu *sync.Mutex, elems *[]ui.Element, frameChan
|
||||||
log.Printf("frameReceiver: loop starting")
|
log.Printf("frameReceiver: loop starting")
|
||||||
for {
|
for {
|
||||||
frame := <-frameChan
|
frame := <-frameChan
|
||||||
log.Printf("frameReceiver: received frame")
|
//log.Printf("frameReceiver: received frame")
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
*elems = frame
|
*elems = frame
|
||||||
w.Invalidate()
|
w.Invalidate()
|
||||||
|
|
|
||||||
|
|
@ -407,15 +407,49 @@ func (cb *ChunkedBuffer) Delete(pos, n int) {
|
||||||
|
|
||||||
// VisibleByteRange returns the byte range [start, end) of content
|
// VisibleByteRange returns the byte range [start, end) of content
|
||||||
// visible in the viewport, given the current scroll offset and viewport height.
|
// 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) {
|
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) {
|
||||||
// This function relies on LineIndex being available for precise calculations.
|
// If wrapping and we have visual line data, use it for precise calculation
|
||||||
// Fallback to estimate if LineIndex is nil.
|
if wordWrap && len(layout.VisualLineStarts) > 0 {
|
||||||
if cb.LineIndex == nil {
|
// Calculate which visual line should be at the top based on scroll offset
|
||||||
// Fallback: estimate using average line height (used during index build)
|
visualLine := int(scrollOffset / lineHeight)
|
||||||
return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight)
|
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
|
// visibleByteRangeEstimate approximates the visible byte range using
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,11 @@ type Logic struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogic creates a new Logic instance, accepting an optional mockFS.
|
// 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()
|
state := NewState()
|
||||||
TheState = state
|
TheState = state
|
||||||
|
TheState.open = openfunc
|
||||||
|
ui.OpenFile = openfunc
|
||||||
|
|
||||||
// Initialize mock filesystem if nil
|
// Initialize mock filesystem if nil
|
||||||
mockFS := mfs
|
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 := pool.NewWorkerPool(8) // Increased worker pool size to 8
|
||||||
wp.Start()
|
wp.Start()
|
||||||
|
|
||||||
// Set browser initial path
|
|
||||||
path := "/"
|
|
||||||
if len(startPath) > 0 {
|
|
||||||
path = startPath[0]
|
|
||||||
}
|
|
||||||
state.Browser.CurrentPath = path
|
state.Browser.CurrentPath = path
|
||||||
bm, _ := browser.NewBrowserManager(&state.Browser, wp, mockFS)
|
bm, _ := browser.NewBrowserManager(&state.Browser, wp, mockFS)
|
||||||
|
|
||||||
|
|
@ -166,6 +163,7 @@ func (l *Logic) Run() {
|
||||||
// Store the full GlyphLayout on editor state.
|
// Store the full GlyphLayout on editor state.
|
||||||
// Derive LastLineY from it for scroll clamping.
|
// Derive LastLineY from it for scroll clamping.
|
||||||
l.state.Editor.GlyphLayout = layout
|
l.state.Editor.GlyphLayout = layout
|
||||||
|
log.Printf("LOGIC received GlyphLayout: ByteOffsets=%d, VisualLineStarts=%d", len(layout.ByteOffsets), len(layout.VisualLineStarts))
|
||||||
var derivedLastLineY ui.Dp
|
var derivedLastLineY ui.Dp
|
||||||
if len(layout.Y) > 0 {
|
if len(layout.Y) > 0 {
|
||||||
derivedLastLineY = layout.Y[len(layout.Y)-1]
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
@ -13,10 +14,6 @@ import (
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
|
||||||
ui.OpenFile = OpenFile
|
|
||||||
}
|
|
||||||
|
|
||||||
// EditorFontSize is the font size used for editor text.
|
// EditorFontSize is the font size used for editor text.
|
||||||
const EditorFontSize = 14 // unit.Sp
|
const EditorFontSize = 14 // unit.Sp
|
||||||
|
|
||||||
|
|
@ -116,6 +113,7 @@ type State struct {
|
||||||
page Page // current page (Browser or Editor)
|
page Page // current page (Browser or Editor)
|
||||||
WordWrap bool
|
WordWrap bool
|
||||||
ScrollOffset ui.Dp // vertical scroll position in Dp
|
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
|
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
|
||||||
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
|
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
|
||||||
FocusedElementID string // ID of the currently focused element
|
FocusedElementID string // ID of the currently focused element
|
||||||
|
|
@ -125,12 +123,14 @@ type State struct {
|
||||||
Browser browser.BrowserState // Embedded, not a pointer
|
Browser browser.BrowserState // Embedded, not a pointer
|
||||||
// Editor state
|
// Editor state
|
||||||
Editor EditorState // New field
|
Editor EditorState // New field
|
||||||
|
open func(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewState() *State {
|
func NewState() *State {
|
||||||
return &State{
|
return &State{
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
page: BrowserPage, // Reverted to BrowserPage
|
page: BrowserPage, // Reverted to BrowserPage
|
||||||
|
WordWrap: true, // Enable word wrap by default
|
||||||
lastEvictionTime: time.Now(),
|
lastEvictionTime: time.Now(),
|
||||||
Browser: *browser.NewBrowserState(),
|
Browser: *browser.NewBrowserState(),
|
||||||
Editor: EditorState{
|
Editor: EditorState{
|
||||||
|
|
@ -245,6 +245,7 @@ func GoToEditor(data any) {
|
||||||
// data is the filename string from the browser list.
|
// data is the filename string from the browser list.
|
||||||
func OpenFile(data any) {
|
func OpenFile(data any) {
|
||||||
filename := data.(string)
|
filename := data.(string)
|
||||||
|
TheState.open(filename)
|
||||||
|
|
||||||
// Dispatch a request to load the file
|
// Dispatch a request to load the file
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -700,11 +701,16 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
var visibleContent string
|
var visibleContent string
|
||||||
var visibleCursorPos int
|
var visibleCursorPos int
|
||||||
var visibleScrollOffset ui.Dp
|
var visibleScrollOffset ui.Dp
|
||||||
|
var start, end int
|
||||||
|
|
||||||
cb := TheState.Editor.ChunkedBuffer
|
cb := TheState.Editor.ChunkedBuffer
|
||||||
if cb != nil {
|
if cb != nil {
|
||||||
viewportHeight := editorRegion.H
|
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
|
// Proactively load chunks needed for the current viewport
|
||||||
startChunk := start / cb.ChunkSize()
|
startChunk := start / cb.ChunkSize()
|
||||||
|
|
@ -716,7 +722,8 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract visible content from chunked buffer
|
// 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
|
// Adjust cursor position to be relative to visibleContent
|
||||||
visibleCursorPos = TheState.Editor.CursorPosition - start
|
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
|
// 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
|
// Map scroll offset to a chunk index
|
||||||
// Estimate line-to-byte conversion if LineIndex is missing
|
// 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.
|
// Content() will load chunks immediately needed by the viewport.
|
||||||
cb.Prefetch(scrollChunk, 1)
|
cb.Prefetch(scrollChunk, 1)
|
||||||
} else {
|
} else {
|
||||||
|
fmt.Printf("LAYOUT: fallback, no cb\n")
|
||||||
// Fallback: no chunked buffer, use full buffer (small files)
|
// Fallback: no chunked buffer, use full buffer (small files)
|
||||||
visibleContent = TheState.Editor.Buffer
|
visibleContent = TheState.Editor.Buffer
|
||||||
visibleCursorPos = TheState.Editor.CursorPosition
|
visibleCursorPos = TheState.Editor.CursorPosition
|
||||||
visibleScrollOffset = TheState.ScrollOffset
|
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.
|
// Add the TextField back in a way that passes the test.
|
||||||
editorElem := ui.NewTextField(
|
editorElem := ui.NewTextField(
|
||||||
"editor_text",
|
"editor_text",
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,9 @@ const (
|
||||||
TypeStatFile
|
TypeStatFile
|
||||||
TypeBuildLineIndex
|
TypeBuildLineIndex
|
||||||
TypeWriteFile
|
TypeWriteFile
|
||||||
|
TypeBuildIndex
|
||||||
// Browser task types
|
// Browser task types
|
||||||
TypeReadDir
|
TypeReadDir
|
||||||
TypeBuildIndex
|
|
||||||
TypeLoadIndex
|
TypeLoadIndex
|
||||||
TypeLoadPages
|
TypeLoadPages
|
||||||
TypeStatDir
|
TypeStatDir
|
||||||
|
|
@ -85,10 +85,10 @@ func (t TaskType) String() string {
|
||||||
return "build_line_index"
|
return "build_line_index"
|
||||||
case TypeWriteFile:
|
case TypeWriteFile:
|
||||||
return "write_file"
|
return "write_file"
|
||||||
case TypeReadDir:
|
|
||||||
return "read_dir"
|
|
||||||
case TypeBuildIndex:
|
case TypeBuildIndex:
|
||||||
return "build_index"
|
return "build_index"
|
||||||
|
case TypeReadDir:
|
||||||
|
return "read_dir"
|
||||||
case TypeLoadIndex:
|
case TypeLoadIndex:
|
||||||
return "load_index"
|
return "load_index"
|
||||||
case TypeLoadPages:
|
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) Timeout() time.Duration { return 5 * time.Second }
|
||||||
func (t *BuildIndexTask) Cancel() { if t.cancel != nil { t.cancel() } }
|
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 {
|
type LoadPagesTask struct {
|
||||||
taskID string
|
taskID string
|
||||||
Dir string
|
Dir string
|
||||||
PageIdxs []int
|
PageIdxs []int
|
||||||
FS FileSystem
|
FS FileSystem
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLoadPagesTask creates a new LoadPagesTask.
|
// 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.
|
// LineCount returns the number of lines in the index.
|
||||||
func (li *LineIndex) LineCount() int {
|
func (li *LineIndex) LineCount() int {
|
||||||
return len(li.Offsets)
|
return len(li.Offsets)
|
||||||
|
|
@ -43,6 +62,34 @@ func (li *LineIndex) ByteOffset(line int) int {
|
||||||
return int(li.Offsets[line])
|
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.
|
// String returns a string representation of the LineIndex.
|
||||||
func (li *LineIndex) String() string {
|
func (li *LineIndex) String() string {
|
||||||
return fmt.Sprintf("LineIndex{lines=%d, size=%d, mtime=%d}", len(li.Offsets), li.Size, li.Mtime)
|
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.
|
// OpenFile is called by the browser list when a file row is tapped.
|
||||||
// Set by the editor package after initialization.
|
// Set by the editor package after initialization.
|
||||||
var OpenFile func(any)
|
var OpenFile func(string)
|
||||||
|
|
||||||
// Color is an RGBA color.
|
// Color is an RGBA color.
|
||||||
type Color struct {
|
type Color struct {
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ func (r *Renderer) LastLineY() Dp {
|
||||||
// drawWrappedText call. Used by the logic goroutine to position the cursor
|
// drawWrappedText call. Used by the logic goroutine to position the cursor
|
||||||
// and navigate by glyph instead of byte offset.
|
// and navigate by glyph instead of byte offset.
|
||||||
func (r *Renderer) GlyphLayout() GlyphLayout {
|
func (r *Renderer) GlyphLayout() GlyphLayout {
|
||||||
|
log.Printf("VisualLineStarts = %v", r.glyphLayout.VisualLineStarts)
|
||||||
return r.glyphLayout
|
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.
|
// Capture per-glyph layout data for cursor positioning and navigation.
|
||||||
var layout GlyphLayout
|
var layout GlyphLayout
|
||||||
|
layout.LineHeight = Dp(float32(lineHeightSp))
|
||||||
byteOffset := 0
|
byteOffset := 0
|
||||||
|
layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset)
|
||||||
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
|
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
|
||||||
// Record layout data for this glyph.
|
// Record layout data for this glyph.
|
||||||
// g.X is in fixed.Int26_6 — shift >> 6 for device pixels, divide by scale for Dp.
|
// 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]
|
line = line[:0]
|
||||||
if g.Flags&text.FlagLineBreak != 0 {
|
if g.Flags&text.FlagLineBreak != 0 {
|
||||||
lineCount++
|
lineCount++
|
||||||
|
layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package ui
|
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,
|
// Dp represents device-independent pixels. Use for all element positions,
|
||||||
// sizes, and spacing in the logic/layout layer.
|
// 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
|
// 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
|
// 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.
|
// 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 {
|
type GlyphLayout struct {
|
||||||
ByteOffsets []int // byte offset of each glyph in the buffer
|
ByteOffsets []int // byte offset of each glyph in the buffer
|
||||||
X []Dp // screen X (Dp) of each glyph, relative to text region origin
|
X []Dp // screen X (Dp) of each glyph, relative to text region origin
|
||||||
Y []Dp // screen Y (Dp) baseline of each glyph (shaper value)
|
Y []Dp // screen Y (Dp) baseline of each glyph (shaper value)
|
||||||
Advance []Dp // advance width (Dp) of each glyph
|
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