package editor import ( "testing" "gioui.org/io/key" "pad/internal/ui" ) // selState sets up a fresh state with a string buffer (the selection logic // is buffer-implementation independent; chunked-buffer coverage is in the // e2e real-file tests). func selState(content string) { TheState = NewState() TheState.Editor.Buffer = content TheState.Editor.CursorPosition = len(content) } // assertSelection checks the normalized selection triple. func assertSelection(t *testing.T, wantStart, wantEnd int) { t.Helper() e := TheState.Editor if e.SelectionStart != wantStart || e.SelectionEnd != wantEnd { t.Fatalf("selection = [%d,%d), want [%d,%d)", e.SelectionStart, e.SelectionEnd, wantStart, wantEnd) } active := wantStart >= 0 && wantEnd > wantStart if active != selActive() { t.Fatalf("selActive() = %v, want %v (anchor=%d)", selActive(), active, e.SelectionAnchor) } } func TestSetSelection_Basic(t *testing.T) { selState("0123456789") SetSelection(2, 7) assertSelection(t, 2, 7) if TheState.Editor.SelectionAnchor != 2 { t.Errorf("anchor = %d, want 2", TheState.Editor.SelectionAnchor) } if TheState.Editor.CursorPosition != 7 { t.Errorf("cursor = %d, want 7 (active end)", TheState.Editor.CursorPosition) } // Reversed arguments are normalized. SetSelection(9, 4) assertSelection(t, 4, 9) if TheState.Editor.SelectionAnchor != 4 { t.Errorf("anchor = %d, want 4", TheState.Editor.SelectionAnchor) } } func TestSetSelection_Clamp(t *testing.T) { selState("0123456789") // End past EOF clamps to file length. SetSelection(7, 100) assertSelection(t, 7, 10) // Negative start clamps to 0. SetSelection(-5, 3) assertSelection(t, 0, 3) // Degenerate range clears instead of selecting. SetSelection(5, 5) assertSelection(t, -1, -1) if TheState.Editor.CursorPosition != 5 { t.Errorf("cursor = %d, want 5", TheState.Editor.CursorPosition) } } func TestShiftExtend_Horizontal(t *testing.T) { selState("0123456789") TheState.Editor.CursorPosition = 3 // First shift-move anchors at the cursor and selects one char. handleKey(key.NameRightArrow, true) assertSelection(t, 3, 4) // Further shift-moves extend from the anchor. handleKey(key.NameRightArrow, true) assertSelection(t, 3, 5) handleKey(key.NameRightArrow, true) assertSelection(t, 3, 6) // Left moves shrink back toward the anchor; crossing it keeps the // selection (anchor is the fixed end). handleKey(key.NameLeftArrow, true) assertSelection(t, 3, 5) handleKey(key.NameLeftArrow, true) assertSelection(t, 3, 4) // Crossing the anchor: zero-length, represented as no selection. handleKey(key.NameLeftArrow, true) assertSelection(t, -1, -1) // ...but the anchor survives so the next shift-move extends from the // original spot. if TheState.Editor.SelectionAnchor != 3 { t.Errorf("anchor = %d, want 3 (kept across zero-length)", TheState.Editor.SelectionAnchor) } handleKey(key.NameLeftArrow, true) assertSelection(t, 2, 3) // A plain move clears the selection and moves one step (cursor 2 -> 1). handleKey(key.NameLeftArrow, false) assertSelection(t, -1, -1) if TheState.Editor.SelectionAnchor != -1 { t.Errorf("anchor = %d, want -1 after plain move", TheState.Editor.SelectionAnchor) } if TheState.Editor.CursorPosition != 1 { t.Errorf("cursor = %d, want 1", TheState.Editor.CursorPosition) } } func TestShiftExtend_UpDownHomeEnd(t *testing.T) { TheState = NewState() TheState.Editor.Buffer = "hello\nworld" TheState.Editor.CursorPosition = 8 // line 1, inside "world" TheState.Editor.GlyphLayout = makeTestLineLayout("hello\nworld") // Layout: line 0 "hello" baseline Y=70, line 1 "world" baseline Y=140; // X = 10 + 10*(column). Cursor 8 is line 1, column 2 (X=30). handleKey(key.NameUpArrow, true) // Up goes to the closest X on line 0: X=30 is byte 2 exactly. assertSelection(t, 2, 8) if TheState.Editor.CursorPosition != 2 { t.Fatalf("cursor = %d, want 2", TheState.Editor.CursorPosition) } // Shift+End moves to end of line 0 (byte 5); the anchor stays at 8. handleKey(key.NameEnd, true) assertSelection(t, 5, 8) // Plain Down clears and moves to the closest X on line 1. Cursor is at // line 0 col 4 (X=50) -> line 1 col 4 = byte 10. handleKey(key.NameDownArrow, false) assertSelection(t, -1, -1) if TheState.Editor.CursorPosition != 10 { t.Fatalf("cursor = %d, want 10", TheState.Editor.CursorPosition) } // Shift+End extends to end of file (byte 11), anchoring at 10. handleKey(key.NameEnd, true) assertSelection(t, 10, 11) // Shift+Home selects back to line start (byte 6), anchor 10 kept. handleKey(key.NameHome, true) assertSelection(t, 6, 10) // Plain Home clears and moves. handleKey(key.NameHome, false) assertSelection(t, -1, -1) if TheState.Editor.CursorPosition != 6 { t.Errorf("cursor = %d, want 6", TheState.Editor.CursorPosition) } } func TestInsert_ReplacesSelection(t *testing.T) { selState("hello world") SetSelection(5, 11) // " world" HandleInsert("GO") if TheState.Editor.Buffer != "helloGO" { t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "helloGO") } if TheState.Editor.CursorPosition != 7 { t.Errorf("cursor = %d, want 7", TheState.Editor.CursorPosition) } assertSelection(t, -1, -1) } func TestBackspace_DeletesSelection(t *testing.T) { selState("0123456789") SetSelection(2, 7) HandleBackspace() if TheState.Editor.Buffer != "01789" { t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "01789") } if TheState.Editor.CursorPosition != 2 { t.Errorf("cursor = %d, want 2", TheState.Editor.CursorPosition) } assertSelection(t, -1, -1) } func TestDeleteForward_DeletesSelection(t *testing.T) { selState("0123456789") SetSelection(2, 7) HandleDelete() if TheState.Editor.Buffer != "01789" { t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "01789") } if TheState.Editor.CursorPosition != 2 { t.Errorf("cursor = %d, want 2", TheState.Editor.CursorPosition) } assertSelection(t, -1, -1) } func TestBackspace_NoSelection_StillDeletesChar(t *testing.T) { selState("0123456789") TheState.Editor.CursorPosition = 5 HandleBackspace() // Deletes the char before the cursor (index 4, '4'). if TheState.Editor.Buffer != "012356789" { t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "012356789") } if TheState.Editor.CursorPosition != 4 { t.Errorf("cursor = %d, want 4", TheState.Editor.CursorPosition) } } func TestHandleReplaceRange_UnionsSelection(t *testing.T) { selState("hello world") SetSelection(0, 5) // "hello", cursor at 5 // IME reports an empty range at the caret: the selection must still be // consumed. HandleReplaceRange(5, 5, "X") if TheState.Editor.Buffer != "X world" { t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "X world") } if TheState.Editor.CursorPosition != 1 { t.Errorf("cursor = %d, want 1", TheState.Editor.CursorPosition) } assertSelection(t, -1, -1) // IME reports the full selection range: same outcome (idempotent union). selState("hello world") SetSelection(0, 5) HandleReplaceRange(0, 5, "X") if TheState.Editor.Buffer != "X world" { t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "X world") } // No selection: ordinary insert at the caret (regression guard). selState("hello") TheState.Editor.CursorPosition = 5 HandleReplaceRange(5, 5, "!") if TheState.Editor.Buffer != "hello!" { t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "hello!") } if TheState.Editor.CursorPosition != 6 { t.Errorf("cursor = %d, want 6", TheState.Editor.CursorPosition) } } func TestHandleReplaceRange_SelectionPartialOverlap(t *testing.T) { // Selection [2,7); IME commit inserts at caret 7 (just outside the // selection): union [2,7) -> the selected text is replaced, inserted // text ends up where the selection was. selState("0123456789") SetSelection(2, 7) HandleReplaceRange(7, 7, "AB") want := "01AB789" if TheState.Editor.Buffer != want { t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, want) } if TheState.Editor.CursorPosition != 4 { t.Errorf("cursor = %d, want 4", TheState.Editor.CursorPosition) } assertSelection(t, -1, -1) } func TestSelectionEdit_UTF8(t *testing.T) { // "hélló": é is 2 bytes (1..3), ó is 2 bytes (5..7). Select the // multi-byte span [1,5) = "éll" and replace it: must not split runes. selState("hélló") SetSelection(1, 5) HandleInsert("a") want := "ha" + "ó" if TheState.Editor.Buffer != want { t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, want) } if TheState.Editor.CursorPosition != 2 { t.Errorf("cursor = %d, want 2", TheState.Editor.CursorPosition) } } func TestSetCursorFromPoint_ClearsSelection(t *testing.T) { TheState = NewState() TheState.Editor.Buffer = "hello\nworld" TheState.Editor.GlyphLayout = makeTestLineLayout("hello\nworld") SetSelection(0, 5) assertSelection(t, 0, 5) // Tap on the second line (Y of line 1 glyphs). SetCursorFromPoint(25, secondLineY(t)) assertSelection(t, -1, -1) if TheState.Editor.CursorPosition < 6 || TheState.Editor.CursorPosition > 11 { t.Errorf("cursor = %d, want in [6,11] (line 1)", TheState.Editor.CursorPosition) } } func TestBareKeyName_ShiftIgnored(t *testing.T) { // The legacy bare key.Name path has no modifier state: it must never // start a selection. selState("0123456789") TheState.Editor.CursorPosition = 3 HandleKeyDown(key.NameRightArrow) assertSelection(t, -1, -1) if TheState.Editor.CursorPosition != 4 { t.Errorf("cursor = %d, want 4", TheState.Editor.CursorPosition) } } func TestKeyEvent_DoesNotAffectIMEEvents(t *testing.T) { // key.EditEvent still flows to HandleReplaceRange unchanged. selState("hello") TheState.Editor.CursorPosition = 5 HandleKeyDown(key.EditEvent{Range: key.Range{Start: 5, End: 5}, Text: "!"}) if TheState.Editor.Buffer != "hello!" { t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "hello!") } } // makeTestLineLayout builds a GlyphLayout for an ASCII buffer whose lines // are laid out at fixed baselines (line i at Y=70+70*i), one glyph per byte // with X = 10 + 10*column and advance 10. func makeTestLineLayout(content string) ui.GlyphLayout { var layout ui.GlyphLayout y := 70 col := 0 for i := 0; i < len(content); i++ { if i > 0 && content[i-1] == '\n' { y += 70 col = 0 } layout.ByteOffsets = append(layout.ByteOffsets, i) layout.X = append(layout.X, 10+ui.Dp(col)*10) layout.Y = append(layout.Y, ui.Dp(y)) layout.Advance = append(layout.Advance, 10) if content[i] != '\n' { col++ } } layout.LineHeight = 20 return layout } // secondLineY returns the Y of the first glyph of the second line in the // layout produced by makeTestLineLayout. func secondLineY(t *testing.T) float64 { t.Helper() l := TheState.Editor.GlyphLayout for i := 1; i < len(l.Y); i++ { if l.Y[i] > l.Y[0] { return float64(l.Y[i]) } } t.Fatal("layout has no second line") return 0 }