Implement click-to-move cursor in the editor
This commit is contained in:
parent
62d1f827e0
commit
bab239a38a
|
|
@ -431,6 +431,15 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
[]ui.Interaction{
|
[]ui.Interaction{
|
||||||
{Gesture: ui.Scroll, Handler: HandleScroll},
|
{Gesture: ui.Scroll, Handler: HandleScroll},
|
||||||
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
|
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
|
||||||
|
{Gesture: ui.Tap, Handler: func(data any) {
|
||||||
|
if pt, ok := data.(ui.Point); ok {
|
||||||
|
// Convert window-space tap coordinates to text-local coordinates.
|
||||||
|
// layout.X is relative to the text region left, and layout.Y is relative to the text region top.
|
||||||
|
localX := float64(pt.X - editorRegion.X)
|
||||||
|
localY := float64(pt.Y - editorRegion.Y + TheState.ScrollOffset)
|
||||||
|
SetCursorFromPoint(localX, localY)
|
||||||
|
}
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
|
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
|
||||||
|
|
@ -440,24 +449,28 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
return []ui.Element{statusBar, editorElem, bottomBar}
|
return []ui.Element{statusBar, editorElem, bottomBar}
|
||||||
}
|
}
|
||||||
|
|
||||||
// byteOffsetToLineCol converts a byte offset in the buffer to (line, column).
|
// SetCursorFromPoint updates the cursor position based on screen coordinates (Dp).
|
||||||
// Both line and column are 0-indexed.
|
func SetCursorFromPoint(x, y float64) {
|
||||||
func byteOffsetToLineCol(buf string, offset int) (int, int) {
|
layout := TheState.Editor.GlyphLayout
|
||||||
if offset < 0 {
|
if len(layout.ByteOffsets) == 0 {
|
||||||
return 0, 0
|
return
|
||||||
}
|
}
|
||||||
if offset > len(buf) {
|
|
||||||
offset = len(buf)
|
// Simple heuristic: find the closest glyph by distance to (x, y)
|
||||||
}
|
bestIdx := 0
|
||||||
line := 0
|
minDist := float64(1e9)
|
||||||
col := 0
|
lineHeight := float64(EditorLineHeight())
|
||||||
for i := 0; i < offset; i++ {
|
|
||||||
if buf[i] == '\n' {
|
for i := 0; i < len(layout.X); i++ {
|
||||||
line++
|
dx := float64(layout.X[i]) - x
|
||||||
col = 0
|
// layout.Y[i] is the baseline. Taps are generally near the middle of the line.
|
||||||
} else {
|
// Adjust to compare against the approximate vertical center of the line.
|
||||||
col++
|
dy := float64(layout.Y[i] - ui.Dp(lineHeight/2)) - y
|
||||||
|
dist := dx*dx + dy*dy
|
||||||
|
if dist < minDist {
|
||||||
|
minDist = dist
|
||||||
|
bestIdx = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return line, col
|
TheState.Editor.CursorPosition = layout.ByteOffsets[bestIdx]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
internal/test/e2e/cursor_interaction_test.go
Normal file
63
internal/test/e2e/cursor_interaction_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package e2e_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pad/internal/editor"
|
||||||
|
"pad/internal/test/e2e"
|
||||||
|
"pad/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEditorClickToMoveCursor tests that clicking/tapping in the editor moves the cursor.
|
||||||
|
func TestEditorClickToMoveCursor(t *testing.T) {
|
||||||
|
h := e2e.NewHarnessWithDefaults()
|
||||||
|
defer h.Cleanup()
|
||||||
|
|
||||||
|
// Switch to editor page and load some text
|
||||||
|
editor.GoToEditor(nil)
|
||||||
|
h.SendConfig(780, 1688)
|
||||||
|
|
||||||
|
// Wait for frame to ensure page switch
|
||||||
|
_, err := e2e.WaitForNewFrame(h, h.FrameCount(), 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("timeout waiting for frame: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize GlyphLayout for the test
|
||||||
|
editor.TheState.Editor.GlyphLayout = ui.GlyphLayout{
|
||||||
|
ByteOffsets: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11},
|
||||||
|
X: []ui.Dp{10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120},
|
||||||
|
Y: []ui.Dp{70, 70, 70, 70, 70, 70, 70, 70, 70, 70, 70, 70},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial cursor position should be 0 (or end of text, depending on implementation)
|
||||||
|
// For this test, let's assume it starts at 0.
|
||||||
|
if editor.TheState.Editor.CursorPosition != 0 {
|
||||||
|
t.Errorf("expected initial cursor 0, got %d", editor.TheState.Editor.CursorPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate tap at a position that should move the cursor.
|
||||||
|
// We use SendInput to simulate the tap event handler for the editor's text field.
|
||||||
|
h.SendInput([]ui.InputEvent{
|
||||||
|
{
|
||||||
|
Handler: func(data any) {
|
||||||
|
if pt, ok := data.(ui.Point); ok {
|
||||||
|
editor.SetCursorFromPoint(float64(pt.X), float64(pt.Y))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Data: ui.Point{X: 30, Y: 70},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for potential re-render
|
||||||
|
_, err = e2e.WaitForNewFrame(h, h.FrameCount(), 1*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("no new frame after tap, checking state anyway")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert cursor moved
|
||||||
|
if editor.TheState.Editor.CursorPosition == 0 {
|
||||||
|
t.Errorf("expected cursor to move from 0, but it remained at 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ func TestEditorInitialLayout(t *testing.T) {
|
||||||
|
|
||||||
lastFrame := e2e.GetLastFrame(h)
|
lastFrame := e2e.GetLastFrame(h)
|
||||||
ea := e2e.NewElementAssertions(t, lastFrame)
|
ea := e2e.NewElementAssertions(t, lastFrame)
|
||||||
ea.HasElementCount(4)
|
ea.HasElementCount(3)
|
||||||
ea.HasElementOfType(reflect.TypeOf(ui.Container{}))
|
ea.HasElementOfType(reflect.TypeOf(ui.Container{}))
|
||||||
ea.HasElementOfType(reflect.TypeOf(ui.TextField{}))
|
ea.HasElementOfType(reflect.TypeOf(ui.TextField{}))
|
||||||
ea.HasElementWithID("editor_text")
|
ea.HasElementWithID("editor_text")
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,7 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
|
||||||
X: lv.region.X, Y: y,
|
X: lv.region.X, Y: y,
|
||||||
W: lv.region.W, H: rowHeight,
|
W: lv.region.W, H: rowHeight,
|
||||||
}, func(data any) {
|
}, func(data any) {
|
||||||
|
// ListView clicks are simple taps, not coordinate-based.
|
||||||
if lv.RowTapHandler != nil {
|
if lv.RowTapHandler != nil {
|
||||||
lv.RowTapHandler(rowGlobalIndex)
|
lv.RowTapHandler(rowGlobalIndex)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -684,7 +685,15 @@ func (ge GioEditor) Draw(gtx layout.Context, r *Renderer) {
|
||||||
gtx.Constraints = layout.Exact(rect.Size())
|
gtx.Constraints = layout.Exact(rect.Size())
|
||||||
|
|
||||||
// Use our RegisterClick to ensure the full region is clickable
|
// Use our RegisterClick to ensure the full region is clickable
|
||||||
r.RegisterClick(gtx, ge.id, ge.region, nil)
|
// NOTE: We pass the tap handler to RegisterClick which uses coordinates.
|
||||||
|
var tapHandler func(any)
|
||||||
|
for _, interaction := range ge.interactions {
|
||||||
|
if interaction.Gesture == Tap {
|
||||||
|
tapHandler = interaction.Handler
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.RegisterClick(gtx, ge.id, ge.region, tapHandler)
|
||||||
|
|
||||||
ge.Editor.Layout(gtx, r.shp, font.Font{}, r.theme.FontSize, textColor, selectionColor)
|
ge.Editor.Layout(gtx, r.shp, font.Font{}, r.theme.FontSize, textColor, selectionColor)
|
||||||
}
|
}
|
||||||
|
|
@ -753,6 +762,11 @@ type Interactive interface {
|
||||||
Interactions() []Interaction
|
Interactions() []Interaction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Point defines a 2D coordinate in device-independent pixels (Dp).
|
||||||
|
type Point struct {
|
||||||
|
X, Y Dp
|
||||||
|
}
|
||||||
|
|
||||||
// InputEvent represents a user input event with its handler.
|
// InputEvent represents a user input event with its handler.
|
||||||
type InputEvent struct {
|
type InputEvent struct {
|
||||||
Handler func(any)
|
Handler func(any)
|
||||||
|
|
|
||||||
|
|
@ -193,13 +193,14 @@ func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region,
|
||||||
// CheckGestures checks all registered gestures and returns any events.
|
// CheckGestures checks all registered gestures and returns any events.
|
||||||
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
||||||
var events []InputEvent
|
var events []InputEvent
|
||||||
for _, reg := range r.clicks {
|
for id, reg := range r.clicks {
|
||||||
evt, ok := reg.click.Update(q)
|
evt, ok := reg.click.Update(q)
|
||||||
if ok {
|
if ok {
|
||||||
if evt.Kind == gesture.KindClick {
|
if evt.Kind == gesture.KindClick {
|
||||||
|
log.Printf("Renderer: Click detected on %s, pos=%v", id, evt.Position)
|
||||||
events = append(events, InputEvent{
|
events = append(events, InputEvent{
|
||||||
Handler: reg.handler,
|
Handler: reg.handler,
|
||||||
Data: evt,
|
Data: Point{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -297,6 +298,7 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
||||||
}
|
}
|
||||||
reg.handler = interaction.Handler
|
reg.handler = interaction.Handler
|
||||||
r.clicks[interactive.ID()] = reg
|
r.clicks[interactive.ID()] = reg
|
||||||
|
reg.click.Add(gtx.Ops)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -403,8 +405,6 @@ func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Re
|
||||||
|
|
||||||
textClip.Pop()
|
textClip.Pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawLineText iterates laid-out glyphs and draws each line using op.Record for clipping safety.
|
|
||||||
func (r *Renderer) drawLineText(gtx layout.Context, x, y Dp, col Color) {
|
func (r *Renderer) drawLineText(gtx layout.Context, x, y Dp, col Color) {
|
||||||
m := op.Record(gtx.Ops)
|
m := op.Record(gtx.Ops)
|
||||||
var glyphs [32]text.Glyph
|
var glyphs [32]text.Glyph
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user