1package main
2
3// Monolithic core of the application. Manages the global editor state, buffer
4// lifecycle, UI rendering, and coordination between different components like
5// LSP, Ollama, and Syntax.
6
7import (
8 "bufio"
9 "fmt"
10 "io"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "runtime"
15 "sort"
16 "strconv"
17 "strings"
18 "time"
19 "unicode"
20
21 "github.com/nsf/termbox-go"
22)
23
24// Mode represents the current operational state of the editor.
25type Mode int
26
27const (
28 ModeNormal Mode = iota
29 ModeInsert
30 ModeCommand // Colon command line mode
31 ModeFuzzy // File/buffer fuzzy finder mode
32 ModeVisual // Character-wise selection
33 ModeVisualLine // Line-wise selection
34 ModeFind // In-file search mode (/)
35 ModeReplace // Pattern replacement mode
36 ModeVisualBlock // Columnar selection
37 ModeConfirm // Yes/No confirmation prompt
38)
39
40type FuzzyType int
41
42const (
43 FuzzyModeFile FuzzyType = iota
44 FuzzyModeBuffer
45 FuzzyModeWarning
46)
47
48type Jump struct {
49 filename string
50 cursorX int
51 cursorY int
52}
53
54type DiagnosticItem struct {
55 filename string
56 line int
57 character int
58 message string
59 severity int
60}
61
62// MatchRange represents a span of text matched by search or replace.
63type MatchRange struct {
64 startLine int
65 startCol int
66 endLine int
67 endCol int
68}
69
70// Editor is the main controller struct that holds all global state.
71type Editor struct {
72 buffers []*Buffer // All open file buffers.
73 activeBufferIndex int // Currently visible buffer.
74 mode Mode // Current editor mode.
75 clipboard []rune // Basic internal clipboard.
76 pendingKey rune // Stores the first character of a multi-key command (e.g., 'g').
77 commandBuffer []rune // Input for the : command line.
78 commandCursorX int // Cursor position within commandBuffer.
79 commandHistory []string // History of executed commands.
80 commandHistoryIdx int // Current position in command history (-1 = not navigating).
81 findBuffer []rune // Input for the / find line.
82 findSavedSearch string // Search term before incremental search started.
83 lastSearch string // The last searched term (for 'n'/'N').
84 fuzzyBuffer []rune // Filter pattern in fuzzy finder.
85 fuzzyResults []string // Filtered items shown to the user.
86 fuzzyResultIndices []int // Map from displayed results back to original candidates.
87 fuzzyIndex int // Highlighted item in the result list.
88 fuzzyScroll int // Viewport offset for the result list.
89 fuzzyCandidates []string // Raw list of all possible items (files/buffers/etc.).
90 fuzzyType FuzzyType // What the fuzzy finder is searching for.
91 fuzzyDiagnostics []DiagnosticItem // Diagnostics from all buffers (accessible via finder).
92 mouseEnabled bool // Toggle for mouse support.
93 visualStartX int // Starting anchor for visual selection.
94 visualStartY int // Starting anchor for visual selection.
95 logMessages []string // Internal debug logs shown in the Log window.
96 maxLogMessages int // Maximum capacity of the log ring buffer.
97 showDebugLog bool // Visibility toggle for the log window.
98 jumplist []Jump // History of cursor locations (for Ctrl-O/Ctrl-I).
99 jumpIndex int // Current position in the jumplist.
100 message string // Status message shown at the bottom.
101 commands *Command // Command handler instance.
102 devMode bool // Internal developer mode toggle.
103 ollamaClient *OllamaClient // Client for local AI features.
104 introDismissed bool // Whether the splash screen was hidden.
105
106 // Replace mode state (regex replacement UI)
107 replaceInput []rune
108 replaceSelStartX int
109 replaceSelStartY int
110 replaceSelEndX int
111 replaceSelEndY int
112 replaceMatches []MatchRange
113 pendingConfirm func() // Callback for the confirmation mode.
114 hoverContent string // Text content for the LSP hover popup.
115 showHover bool // Visibility toggle for the hover popup.
116
117 // Autocomplete state
118 showAutocomplete bool // Visibility toggle for the autocomplete popup.
119 autocompleteItems []CompletionItem // List of completion suggestions from LSP.
120 autocompleteIndex int // Currently selected item in the autocomplete list.
121 autocompleteScroll int // Scroll offset for autocomplete popup.
122}
123
124// activeBuffer returns the Buffer currently being edited.
125func (e *Editor) activeBuffer() *Buffer {
126 if len(e.buffers) == 0 {
127 return nil
128 }
129 return e.buffers[e.activeBufferIndex]
130}
131
132func (e *Editor) useTabs() bool {
133 b := e.activeBuffer()
134 if b == nil || b.fileType == nil {
135 return false
136 }
137 return b.fileType.UseTabs
138}
139
140func (e *Editor) markModified() {
141 b := e.activeBuffer()
142 if b != nil {
143 b.modified = true
144 }
145}
146
147func (e *Editor) visualWidth(r rune, currentX int) int {
148 if r == '\t' {
149 b := e.activeBuffer()
150 tabWidth := Config.DefaultTabWidth
151 if b != nil && b.fileType != nil {
152 tabWidth = b.fileType.TabWidth
153 }
154 return tabWidth - (currentX % tabWidth)
155 }
156 return 1
157}
158
159// bufferToVisual converts a buffer column index to its visual column index (explaining tabs).
160func (e *Editor) bufferToVisual(line []rune, bufferX int) int {
161 visualX := 0
162 for i := 0; i < bufferX && i < len(line); i++ {
163 visualX += e.visualWidth(line[i], visualX)
164 }
165 return visualX
166}
167
168func (e *Editor) bufferToString(buffer [][]rune) string {
169 var result strings.Builder
170 for i, line := range buffer {
171 result.WriteString(string(line))
172 if i < len(buffer)-1 {
173 result.WriteString("\n")
174 }
175 }
176 return result.String()
177}
178
179// NewEditor creates a new editor instance with a default empty buffer.
180func NewEditor(devMode bool) *Editor {
181 e := &Editor{
182 buffers: []*Buffer{},
183 activeBufferIndex: 0,
184 mode: ModeNormal,
185 pendingKey: 0,
186 commandBuffer: []rune{},
187 commandHistory: []string{},
188 commandHistoryIdx: -1,
189 findBuffer: []rune{},
190 lastSearch: "",
191 fuzzyBuffer: []rune{},
192 fuzzyResults: []string{},
193 fuzzyIndex: 0,
194 fuzzyScroll: 0,
195 fuzzyCandidates: []string{},
196 mouseEnabled: true,
197 logMessages: []string{},
198 maxLogMessages: 50,
199 showDebugLog: false,
200 jumplist: []Jump{},
201 jumpIndex: -1,
202 devMode: devMode,
203 ollamaClient: NewOllamaClient(),
204 }
205 e.addLog("Editor", "Editor initialized")
206 // Add an initial empty buffer with default file type
207 defaultType := fileTypes[len(fileTypes)-1]
208 e.buffers = append(e.buffers, &Buffer{
209 buffer: [][]rune{{}},
210 undoStack: []HistoryState{},
211 redoStack: []HistoryState{},
212 fileType: defaultType,
213 })
214 e.commands = &Command{e: e}
215 return e
216}
217
218func (e *Editor) addLog(group, msg string) {
219 t := time.Now()
220 timestamp := fmt.Sprintf("[%02d:%01d:%02d]", t.Hour(), t.Minute(), t.Second())
221 logMsg := fmt.Sprintf("%s [%s] %s", timestamp, group, msg)
222 e.logMessages = append(e.logMessages, logMsg)
223
224 if len(e.logMessages) > e.maxLogMessages {
225 e.logMessages = e.logMessages[len(e.logMessages)-e.maxLogMessages:]
226 }
227
228 if Config.UseLogFile {
229 f, err := os.OpenFile(Config.LogFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
230 if err == nil {
231 defer f.Close()
232 f.WriteString(logMsg + "\n")
233 }
234 }
235}
236
237func (e *Editor) toggleDebugWindow() {
238 e.showDebugLog = !e.showDebugLog
239}
240
241// NewBuffer creates a new empty buffer and switches to it.
242func (e *Editor) NewBuffer() {
243 // Use default file type (Text), which is the last one in the list.
244 defaultType := fileTypes[len(fileTypes)-1]
245
246 newB := &Buffer{
247 buffer: [][]rune{{}},
248 filename: "",
249 undoStack: []HistoryState{},
250 redoStack: []HistoryState{},
251 fileType: defaultType,
252 }
253
254 e.buffers = append(e.buffers, newB)
255 e.activeBufferIndex = len(e.buffers) - 1
256 e.message = "New buffer created"
257 e.introDismissed = true
258}
259
260// LoadFile reads a file from disk into the active buffer.
261func (e *Editor) LoadFile(filename string) error {
262 info, err := os.Stat(filename)
263 if os.IsNotExist(err) {
264 // Create subfolders if they don't exist
265 if dir := filepath.Dir(filename); dir != "." {
266 if err := os.MkdirAll(dir, 0755); err != nil {
267 return fmt.Errorf("failed to create directories: %v", err)
268 }
269 }
270 // Create the file
271 file, err := os.Create(filename)
272 if err != nil {
273 return fmt.Errorf("failed to create file: %v", err)
274 }
275 file.Close()
276 // Get info for the newly created file
277 info, err = os.Stat(filename)
278 if err != nil {
279 return err
280 }
281 } else if err != nil {
282 return err
283 }
284
285 file, err := os.Open(filename)
286 if err != nil {
287 return err
288 }
289 defer file.Close()
290 err = e.LoadFromReader(filename, file)
291 if err == nil {
292 if info != nil {
293 e.activeBuffer().lastModTime = info.ModTime()
294 }
295 }
296 return err
297}
298
299func (e *Editor) LoadFromReader(filename string, r io.Reader) error {
300 ft := getFileType(filename)
301
302 var bufferLines [][]rune
303 reader := bufio.NewReader(r)
304 for {
305 line, err := reader.ReadString('\n')
306 if err != nil && err != io.EOF {
307 return err
308 }
309
310 if err == io.EOF && line == "" {
311 break
312 }
313
314 // Remove trailing newline
315 trimmedLine := strings.TrimSuffix(line, "\n")
316 trimmedLine = strings.TrimSuffix(trimmedLine, "\r")
317
318 if !ft.UseTabs {
319 trimmedLine = strings.ReplaceAll(trimmedLine, "\t", strings.Repeat(" ", ft.TabWidth))
320 }
321 bufferLines = append(bufferLines, []rune(trimmedLine))
322
323 if err == io.EOF {
324 break
325 }
326 }
327
328 // Ensure buffer is never empty
329 if len(bufferLines) == 0 {
330 bufferLines = [][]rune{{}}
331 }
332
333 // Check if we should update current buffer or add a new one
334 b := e.activeBuffer()
335 if b != nil && b.filename == "" && len(b.buffer) == 1 && len(b.buffer[0]) == 0 {
336 // reuse current empty buffer
337 b.filename = filename
338 b.buffer = bufferLines
339 b.PrimaryCursor().X = 0
340 b.PrimaryCursor().Y = 0
341 b.scrollX = 0
342 b.scrollY = 0
343 b.undoStack = []HistoryState{}
344 b.redoStack = []HistoryState{}
345 b.redoStack = []HistoryState{}
346 b.fileType = ft
347
348 // Initialize Syntax Highlighter
349 syntax := NewSyntaxHighlighter(ft.Name, e.addLog)
350 if syntax != nil {
351 content := e.bufferToString(bufferLines)
352 syntax.Parse([]byte(content))
353 b.syntax = syntax
354 }
355
356 // Initialize LSP if enabled for this file type
357 if ft.EnableLSP && ft.LSPCommand != "" {
358 e.addLog("LSP", fmt.Sprintf("Starting LSP for %s", filepath.Base(filename)))
359 content := e.bufferToString(bufferLines)
360 lspClient, err := NewLSPClient(filename, content, e.addLog, ft)
361 if err == nil {
362 b.lspClient = lspClient
363 e.addLog("LSP", "LSP client initialized successfully")
364 } else {
365 e.addLog("LSP", fmt.Sprintf("LSP init failed: %v", err))
366 }
367 }
368 } else {
369 // add new buffer
370 newB := &Buffer{
371 buffer: bufferLines,
372 filename: filename,
373 undoStack: []HistoryState{},
374 redoStack: []HistoryState{},
375 fileType: ft,
376 }
377
378 // Initialize Syntax Highlighter
379 syntax := NewSyntaxHighlighter(ft.Name, e.addLog)
380 if syntax != nil {
381 content := e.bufferToString(bufferLines)
382 syntax.Parse([]byte(content))
383 newB.syntax = syntax
384 }
385
386 // Initialize LSP if enabled for this file type
387 if ft.EnableLSP && ft.LSPCommand != "" {
388 e.addLog("LSP", fmt.Sprintf("Starting LSP for %s", filepath.Base(filename)))
389 content := e.bufferToString(bufferLines)
390 lspClient, err := NewLSPClient(filename, content, e.addLog, ft)
391 if err == nil {
392 newB.lspClient = lspClient
393 e.addLog("LSP", "LSP client initialized successfully")
394 } else {
395 e.addLog("LSP", fmt.Sprintf("LSP init failed: %v", err))
396 }
397 }
398
399 e.buffers = append(e.buffers, newB)
400 e.activeBufferIndex = len(e.buffers) - 1
401 }
402
403 e.introDismissed = true
404 return nil
405}
406
407// SaveFile writes the active buffer content back to disk.
408func (e *Editor) SaveFile(force bool) error {
409 b := e.activeBuffer()
410 if b == nil || b.filename == "" {
411 return fmt.Errorf("no filename")
412 }
413
414 // Check for external modifications unless forced.
415 if !force {
416 info, err := os.Stat(b.filename)
417 if err == nil && info.ModTime().After(b.lastModTime) {
418 return fmt.Errorf("file changed on disk")
419 }
420 }
421
422 file, err := os.Create(b.filename)
423 if err != nil {
424 return err
425 }
426 defer file.Close()
427
428 writer := bufio.NewWriter(file)
429 for i, line := range b.buffer {
430 _, err := writer.WriteString(string(line))
431 if err != nil {
432 return err
433 }
434 // Write newline if not the last line (or if buffer should end with newline).
435 if i < len(b.buffer)-1 || (len(b.buffer) > 0 && (len(b.buffer) > 1 || len(b.buffer[0]) > 0)) {
436 _, err = writer.WriteString("\n")
437 if err != nil {
438 return err
439 }
440 }
441 }
442 err = writer.Flush()
443 if err == nil {
444 b.modified = false
445 info, err := os.Stat(b.filename)
446 if err == nil {
447 b.lastModTime = info.ModTime()
448 }
449 }
450 return err
451}
452
453func (e *Editor) nextBuffer() {
454 if len(e.buffers) > 0 {
455 e.activeBufferIndex = (e.activeBufferIndex + 1) % len(e.buffers)
456 }
457}
458
459func (e *Editor) prevBuffer() {
460 if len(e.buffers) > 0 {
461 e.activeBufferIndex = (e.activeBufferIndex - 1 + len(e.buffers)) % len(e.buffers)
462 }
463}
464
465func (e *Editor) ReloadBuffer(b *Buffer) error {
466 if b == nil || b.filename == "" {
467 return fmt.Errorf("no filename")
468 }
469
470 info, err := os.Stat(b.filename)
471 if err != nil {
472 return err
473 }
474
475 file, err := os.Open(b.filename)
476 if err != nil {
477 return err
478 }
479 defer file.Close()
480
481 ft := getFileType(b.filename)
482
483 var bufferLines [][]rune
484 reader := bufio.NewReader(file)
485 for {
486 line, err := reader.ReadString('\n')
487 if err != nil && err != io.EOF {
488 return err
489 }
490
491 if err == io.EOF && line == "" {
492 break
493 }
494
495 trimmedLine := strings.TrimSuffix(line, "\n")
496 trimmedLine = strings.TrimSuffix(trimmedLine, "\r")
497
498 if !ft.UseTabs {
499 trimmedLine = strings.ReplaceAll(trimmedLine, "\t", strings.Repeat(" ", ft.TabWidth))
500 }
501 bufferLines = append(bufferLines, []rune(trimmedLine))
502
503 if err == io.EOF {
504 break
505 }
506 }
507
508 if len(bufferLines) == 0 {
509 bufferLines = [][]rune{{}}
510 }
511
512 b.buffer = bufferLines
513 b.lastModTime = info.ModTime()
514 b.modified = false
515
516 // Adjust cursors if they are out of bounds
517 for i := range b.cursors {
518 c := &b.cursors[i]
519 if c.Y >= len(b.buffer) {
520 c.Y = len(b.buffer) - 1
521 }
522 if c.Y < 0 {
523 c.Y = 0
524 }
525 if c.X > len(b.buffer[c.Y]) {
526 c.X = len(b.buffer[c.Y])
527 }
528 }
529
530 // Reinitialize Syntax Highlighter
531 if b.syntax != nil {
532 b.syntax.Reparse([]byte(b.toString()))
533 } else {
534 syntax := NewSyntaxHighlighter(ft.Name, e.addLog)
535 if syntax != nil {
536 syntax.Parse([]byte(b.toString()))
537 b.syntax = syntax
538 }
539 }
540
541 // Update LSP if active
542 if b.lspClient != nil {
543 b.lspClient.SendDidChange(b.toString())
544 }
545
546 return nil
547}
548
549func (e *Editor) CheckFilesOnDisk() {
550 for _, b := range e.buffers {
551 if b.filename == "" {
552 continue
553 }
554
555 info, err := os.Stat(b.filename)
556 if err != nil {
557 continue
558 }
559
560 if info.ModTime().After(b.lastModTime) {
561 isActive := b == e.activeBuffer()
562 if !b.modified {
563 // Auto reload if not dirty
564 err := e.ReloadBuffer(b)
565 if err == nil {
566 e.addLog("Editor", fmt.Sprintf("Auto-reloaded \"%s\" (changed on disk)", filepath.Base(b.filename)))
567 if isActive {
568 e.message = fmt.Sprintf("\"%s\" reloaded from disk", filepath.Base(b.filename))
569 }
570 } else {
571 e.addLog("Editor", fmt.Sprintf("Failed to auto-reload \"%s\": %v", b.filename, err))
572 }
573 } else if isActive {
574 // Buffer is dirty, just notify the user (only if active)
575 e.message = fmt.Sprintf("WARNING: \"%s\" changed on disk. Use :reload to update.", filepath.Base(b.filename))
576 e.addLog("Editor", fmt.Sprintf("\"%s\" changed on disk but buffer is modified", b.filename))
577 // Update lastModTime so we don't spam the message?
578 // Actually, better to keep it so they realize it's still different.
579 // But we should probably only message if it's the active buffer.
580 }
581 }
582 }
583}
584
585func (e *Editor) PeriodicFileChangesCheck() {
586 go func() {
587 for {
588 time.Sleep(Config.FileCheckInterval)
589 termbox.Interrupt()
590 }
591 }()
592}
593
594func (e *Editor) startFileFuzzyFinder() {
595 e.fuzzyCandidates = []string{}
596 filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
597 if err != nil {
598 return nil
599 }
600 if info.IsDir() {
601 if info.Name() == ".git" || info.Name() == "node_modules" {
602 return filepath.SkipDir
603 }
604 return nil
605 }
606 e.fuzzyCandidates = append(e.fuzzyCandidates, path)
607 return nil
608 })
609 e.fuzzyBuffer = []rune{}
610 e.fuzzyIndex = 0
611 e.fuzzyType = FuzzyModeFile
612 e.updateFuzzyResults()
613 e.mode = ModeFuzzy
614}
615
616func (e *Editor) startBufferFuzzyFinder() {
617 e.fuzzyCandidates = []string{}
618 for _, b := range e.buffers {
619 name := b.filename
620 if name == "" {
621 name = "[No Name]"
622 }
623 e.fuzzyCandidates = append(e.fuzzyCandidates, name)
624 }
625 e.fuzzyBuffer = []rune{}
626 e.fuzzyIndex = 0
627 e.fuzzyType = FuzzyModeBuffer
628 e.updateFuzzyResults()
629 e.mode = ModeFuzzy
630}
631
632func (e *Editor) startWarningsFuzzyFinder() {
633 e.fuzzyCandidates = []string{}
634 e.fuzzyDiagnostics = []DiagnosticItem{}
635
636 // Collect diagnostics from all buffers
637 for _, b := range e.buffers {
638 if len(b.diagnostics) == 0 {
639 continue
640 }
641
642 filename := b.filename
643 if filename == "" {
644 filename = "[No Name]"
645 } else {
646 filename = filepath.Base(filename)
647 }
648
649 for _, diag := range b.diagnostics {
650 // Format: [E] filename:line message
651 severityStr := "?"
652 switch diag.Severity {
653 case 1:
654 severityStr = "E"
655 case 2:
656 severityStr = "W"
657 case 3:
658 severityStr = "I"
659 case 4:
660 severityStr = "H"
661 }
662
663 formattedDiag := fmt.Sprintf("[%s] %s:%d %s",
664 severityStr,
665 filename,
666 diag.Range.Start.Line+1, // Convert to 1-indexed
667 diag.Message)
668
669 e.fuzzyCandidates = append(e.fuzzyCandidates, formattedDiag)
670 e.fuzzyDiagnostics = append(e.fuzzyDiagnostics, DiagnosticItem{
671 filename: b.filename,
672 line: diag.Range.Start.Line,
673 character: diag.Range.Start.Character,
674 message: diag.Message,
675 severity: diag.Severity,
676 })
677 }
678 }
679
680 e.fuzzyBuffer = []rune{}
681 e.fuzzyIndex = 0
682 e.fuzzyType = FuzzyModeWarning
683 e.updateFuzzyResults()
684 e.mode = ModeFuzzy
685}
686
687func fuzzyMatch(query, target string) (int, bool) {
688 if query == "" {
689 return 0, true
690 }
691
692 query = strings.ToLower(query)
693 targetLower := strings.ToLower(target)
694
695 score := 0
696 targetIdx := 0
697 lastMatchIdx := -1
698
699 for _, qRune := range query {
700 found := false
701 for i := targetIdx; i < len(targetLower); i++ {
702 if rune(targetLower[i]) == qRune {
703 // Bonus for consecutive matches
704 if lastMatchIdx != -1 && i == lastMatchIdx+1 {
705 score += 10
706 }
707
708 // Bonus for matches after separators
709 if i == 0 || targetLower[i-1] == '/' || targetLower[i-1] == '_' || targetLower[i-1] == '.' || targetLower[i-1] == '-' {
710 score += 20
711 }
712
713 // Penalty for gaps
714 if lastMatchIdx != -1 {
715 score -= (i - lastMatchIdx - 1)
716 }
717
718 score += 5 // Base match score
719 lastMatchIdx = i
720 targetIdx = i + 1
721 found = true
722 break
723 }
724 }
725 if !found {
726 return 0, false
727 }
728 }
729
730 // Substring match bonus
731 if strings.Contains(targetLower, query) {
732 score += 50
733 }
734
735 // Exact match bonus
736 if targetLower == query {
737 score += 100
738 }
739
740 return score, true
741}
742
743func (e *Editor) updateFuzzyResults() {
744 query := string(e.fuzzyBuffer)
745 if query == "" {
746 e.fuzzyResults = make([]string, len(e.fuzzyCandidates))
747 e.fuzzyResultIndices = make([]int, len(e.fuzzyCandidates))
748 copy(e.fuzzyResults, e.fuzzyCandidates)
749 for i := range e.fuzzyResultIndices {
750 e.fuzzyResultIndices[i] = i
751 }
752 } else {
753 type result struct {
754 path string
755 index int
756 score int
757 }
758 var results []result
759 for i, candidate := range e.fuzzyCandidates {
760 if score, ok := fuzzyMatch(query, candidate); ok {
761 results = append(results, result{candidate, i, score})
762 }
763 }
764
765 sort.Slice(results, func(i, j int) bool {
766 return results[i].score > results[j].score
767 })
768
769 e.fuzzyResults = make([]string, len(results))
770 e.fuzzyResultIndices = make([]int, len(results))
771 for i, res := range results {
772 e.fuzzyResults[i] = res.path
773 e.fuzzyResultIndices[i] = res.index
774 }
775 }
776 if e.fuzzyIndex >= len(e.fuzzyResults) {
777 e.fuzzyIndex = 0
778 }
779 e.fuzzyScroll = 0
780}
781
782func (e *Editor) openSelectedFile() {
783 if len(e.fuzzyResults) == 0 {
784 return
785 }
786 selection := e.fuzzyResults[e.fuzzyIndex]
787
788 if e.fuzzyType == FuzzyModeFile {
789 err := e.LoadFile(selection)
790 if err == nil {
791 e.mode = ModeNormal
792 }
793 } else if e.fuzzyType == FuzzyModeBuffer {
794 for i, b := range e.buffers {
795 name := b.filename
796 if name == "" {
797 name = "[No Name]"
798 }
799 if name == selection {
800 e.activeBufferIndex = i
801 e.mode = ModeNormal
802 break
803 }
804 }
805 } else if e.fuzzyType == FuzzyModeWarning {
806 if e.fuzzyIndex >= len(e.fuzzyResults) || e.fuzzyIndex >= len(e.fuzzyResultIndices) {
807 return
808 }
809
810 // Get the original candidate index
811 diagIndex := e.fuzzyResultIndices[e.fuzzyIndex]
812
813 if diagIndex < 0 || diagIndex >= len(e.fuzzyDiagnostics) {
814 return
815 }
816
817 diagItem := e.fuzzyDiagnostics[diagIndex]
818
819 // Find or load the buffer with this file
820 bufferIndex := -1
821 for i, b := range e.buffers {
822 if b.filename == diagItem.filename {
823 bufferIndex = i
824 break
825 }
826 }
827
828 // If buffer not found, try to load it
829 if bufferIndex == -1 && diagItem.filename != "" {
830 err := e.LoadFile(diagItem.filename)
831 if err == nil {
832 bufferIndex = e.activeBufferIndex
833 }
834 }
835
836 // Navigate to the diagnostic location
837 if bufferIndex != -1 {
838 e.activeBufferIndex = bufferIndex
839 b := e.activeBuffer()
840 if b != nil {
841 // Set cursor to diagnostic line and character
842 if diagItem.line < len(b.buffer) {
843 b.PrimaryCursor().Y = diagItem.line
844 if diagItem.character < len(b.buffer[diagItem.line]) {
845 b.PrimaryCursor().X = diagItem.character
846 } else {
847 b.PrimaryCursor().X = 0
848 }
849 }
850 // Center the screen on the diagnostic line
851 e.centerScreen()
852 }
853 e.mode = ModeNormal
854 }
855 }
856}
857
858func (e *Editor) fuzzyMove(dir int) {
859 if len(e.fuzzyResults) == 0 {
860 return
861 }
862 e.fuzzyIndex += dir
863 if e.fuzzyIndex < 0 {
864 e.fuzzyIndex = len(e.fuzzyResults) - 1
865 } else if e.fuzzyIndex >= len(e.fuzzyResults) {
866 e.fuzzyIndex = 0
867 }
868
869 // Adjust scroll
870 if e.fuzzyIndex < e.fuzzyScroll {
871 e.fuzzyScroll = e.fuzzyIndex
872 } else if e.fuzzyIndex >= e.fuzzyScroll+Config.FuzzyFinderHeight {
873 e.fuzzyScroll = e.fuzzyIndex - Config.FuzzyFinderHeight + 1
874 }
875
876 // Special case for wrapping
877 if e.fuzzyIndex == len(e.fuzzyResults)-1 && e.fuzzyScroll == 0 && len(e.fuzzyResults) > Config.FuzzyFinderHeight {
878 e.fuzzyScroll = len(e.fuzzyResults) - Config.FuzzyFinderHeight
879 }
880 if e.fuzzyIndex == 0 && e.fuzzyScroll > 0 {
881 e.fuzzyScroll = 0
882 }
883}
884
885// insertTab inserts either a literal tab character or an equivalent number of spaces.
886func (e *Editor) insertTab() {
887 b := e.activeBuffer()
888 if b == nil {
889 return
890 }
891 if e.useTabs() {
892 e.insertRune('\t')
893 } else {
894 tabWidth := Config.DefaultTabWidth
895 if b.fileType != nil {
896 tabWidth = b.fileType.TabWidth
897 }
898 for i := 0; i < tabWidth; i++ {
899 e.insertRune(' ')
900 }
901 }
902}
903
904// getSortedCursorsDesc returns a list of cursor pointers sorted by position (bottom-to-top, right-to-left).
905// This sorting is CRITICAL for concurrent text edits to avoid offset corruption.
906func (e *Editor) getSortedCursorsDesc() []*Cursor {
907 b := e.activeBuffer()
908 if b == nil {
909 return nil
910 }
911 cursors := make([]*Cursor, len(b.cursors))
912 for i := range b.cursors {
913 cursors[i] = &b.cursors[i]
914 }
915 sort.Slice(cursors, func(i, j int) bool {
916 if cursors[i].Y != cursors[j].Y {
917 return cursors[i].Y > cursors[j].Y // Lower rows first.
918 }
919 return cursors[i].X > cursors[j].X // Later characters in row first.
920 })
921 return cursors
922}
923
924func (e *Editor) insertRune(r rune) {
925 b := e.activeBuffer()
926 if b == nil {
927 return
928 }
929 if b.readOnly {
930 e.message = "File is read-only"
931 return
932 }
933
934 cursors := e.getSortedCursorsDesc()
935 for _, c := range cursors {
936 line := b.buffer[c.Y]
937 newLine := make([]rune, len(line)+1)
938 copy(newLine[:c.X], line[:c.X])
939 newLine[c.X] = r
940 copy(newLine[c.X+1:], line[c.X:])
941 b.buffer[c.Y] = newLine
942 c.X++
943
944 // Handle syntax update
945 if b.syntax != nil {
946 insertedBytes := uint32(len(string(r)))
947 b.handleEdit(c.Y, c.X-1, 0, insertedBytes, c.Y, b.getLineByteOffset(line, c.X-1), c.Y, b.getLineByteOffset(newLine, c.X))
948 }
949 }
950
951 if b.syntax != nil {
952 b.syntax.Reparse([]byte(b.toString()))
953 }
954 e.markModified()
955
956 // Notify LSP of the change
957 if b.lspClient != nil {
958 b.lspClient.SendDidChange(b.toString())
959 }
960}
961
962// DeleteChar removes the character directly under the cursor.
963func (e *Editor) DeleteChar() {
964 b := e.activeBuffer()
965 if b == nil {
966 return
967 }
968 if b.readOnly {
969 e.message = "File is read-only"
970 return
971 }
972
973 cursors := e.getSortedCursorsDesc()
974 for _, c := range cursors {
975 if c.Y >= len(b.buffer) || c.X >= len(b.buffer[c.Y]) {
976 continue
977 }
978
979 line := b.buffer[c.Y]
980 // Store deleted character in clipboard (primary cursor only).
981 if c == b.PrimaryCursor() {
982 e.clipboard = []rune{line[c.X]}
983 }
984
985 deletedBytes := uint32(len(string(line[c.X])))
986 newLine := append(line[:c.X], line[c.X+1:]...)
987 b.buffer[c.Y] = newLine
988
989 // Ensure cursor doesn't drift past the new end of line.
990 if c.X > 0 && c.X >= len(newLine) {
991 c.X = len(newLine) - 1
992 if c.X < 0 {
993 c.X = 0
994 }
995 }
996
997 if b.syntax != nil {
998 oldColBytes := b.getLineByteOffset(line, c.X)
999 newColBytes := b.getLineByteOffset(newLine, c.X)
1000 b.handleEdit(c.Y, c.X, deletedBytes, 0, c.Y, oldColBytes+deletedBytes, c.Y, newColBytes)
1001 }
1002 }
1003 if b.syntax != nil {
1004 b.syntax.Reparse([]byte(b.toString()))
1005 }
1006 e.markModified()
1007}
1008
1009func (e *Editor) backspace() {
1010 b := e.activeBuffer()
1011 if b == nil {
1012 return
1013 }
1014 if b.readOnly {
1015 e.message = "File is read-only"
1016 return
1017 }
1018
1019 cursors := e.getSortedCursorsDesc()
1020 for _, c := range cursors {
1021 if c.X > 0 {
1022 line := b.buffer[c.Y]
1023 deletedChar := line[c.X-1]
1024 newLine := append(line[:c.X-1], line[c.X:]...)
1025 b.buffer[c.Y] = newLine
1026 c.X--
1027
1028 if b.syntax != nil {
1029 deletedBytes := uint32(len(string(deletedChar)))
1030 oldColBytes := b.getLineByteOffset(line, c.X+1)
1031 newColBytes := b.getLineByteOffset(newLine, c.X)
1032
1033 b.handleEdit(c.Y, c.X, deletedBytes, 0, c.Y, oldColBytes, c.Y, newColBytes)
1034 }
1035 } else if c.Y > 0 {
1036 // Merge with previous line
1037 prevLine := b.buffer[c.Y-1]
1038 c.X = len(prevLine)
1039 b.buffer[c.Y-1] = append(prevLine, b.buffer[c.Y]...)
1040 b.buffer = append(b.buffer[:c.Y], b.buffer[c.Y+1:]...)
1041 // We need to shift cursors that are 'below' the current merge point.
1042 for j := range b.cursors {
1043 if b.cursors[j].Y > c.Y {
1044 b.cursors[j].Y--
1045 }
1046 }
1047
1048 c.Y--
1049
1050 // So I need to find other cursors on the same line that haven't been processed?
1051 // Or just all cursors on the same line.
1052 for j := range b.cursors {
1053 if &b.cursors[j] != c && b.cursors[j].Y == c.Y+1 { // c.Y was decremented
1054 // This cursor was on the line we just merged
1055 b.cursors[j].Y--
1056 b.cursors[j].X += len(prevLine)
1057 }
1058 }
1059
1060 if b.syntax != nil {
1061 b.handleEdit(c.Y, c.X, 1, 0, c.Y+1, 0, c.Y, b.getLineByteOffset(b.buffer[c.Y], c.X))
1062 }
1063 }
1064 }
1065 if b.syntax != nil {
1066 b.syntax.Reparse([]byte(b.toString()))
1067 }
1068 e.markModified()
1069}
1070
1071func (e *Editor) getIndentation(line []rune) []rune {
1072 var indent []rune
1073 for _, r := range line {
1074 if r == ' ' || r == '\t' {
1075 indent = append(indent, r)
1076 } else {
1077 break
1078 }
1079 }
1080 return indent
1081}
1082
1083// insertNewline breaks the line at cursor and handles auto-indentation.
1084func (e *Editor) insertNewline() {
1085 b := e.activeBuffer()
1086 if b == nil {
1087 return
1088 }
1089 if b.readOnly {
1090 e.message = "File is read-only"
1091 return
1092 }
1093
1094 cursors := e.getSortedCursorsDesc()
1095 for _, c := range cursors {
1096 line := b.buffer[c.Y]
1097
1098 // Inherit indentation from the current line.
1099 indent := e.getIndentation(line[:c.X])
1100
1101 // Auto-indent after opening braces.
1102 if c.X > 0 && line[c.X-1] == '{' {
1103 if e.useTabs() {
1104 indent = append(indent, '\t')
1105 } else {
1106 tabWidth := Config.DefaultTabWidth
1107 if b.fileType != nil {
1108 tabWidth = b.fileType.TabWidth
1109 }
1110 indent = append(indent, []rune(strings.Repeat(" ", tabWidth))...)
1111 }
1112 }
1113
1114 remaining := make([]rune, len(line)-c.X)
1115 copy(remaining, line[c.X:])
1116
1117 newLine := append(indent, remaining...)
1118 b.buffer[c.Y] = line[:c.X]
1119
1120 // Insert the new line into the buffer.
1121 newBuffer := make([][]rune, len(b.buffer)+1)
1122 copy(newBuffer[:c.Y+1], b.buffer[:c.Y+1])
1123 newBuffer[c.Y+1] = newLine
1124 copy(newBuffer[c.Y+2:], b.buffer[c.Y+1:])
1125 b.buffer = newBuffer
1126
1127 // Shift all cursors below this point, or later on this same line.
1128 for j := range b.cursors {
1129 if b.cursors[j].Y > c.Y {
1130 b.cursors[j].Y++
1131 } else if b.cursors[j].Y == c.Y && b.cursors[j].X >= c.X && &b.cursors[j] != c {
1132 b.cursors[j].Y++
1133 b.cursors[j].X = len(indent) + (b.cursors[j].X - c.X)
1134 }
1135 }
1136
1137 oldCursorX := c.X
1138 c.Y++
1139 c.X = len(indent)
1140
1141 if b.syntax != nil {
1142 insertedBytes := uint32(1 + len(string(indent)))
1143 b.handleEdit(c.Y-1, oldCursorX, 0, insertedBytes, c.Y-1, b.getLineByteOffset(b.buffer[c.Y-1], oldCursorX), c.Y, b.getLineByteOffset(b.buffer[c.Y], c.X))
1144 }
1145 }
1146 if b.syntax != nil {
1147 b.syntax.Reparse([]byte(b.toString()))
1148 }
1149 e.markModified()
1150}
1151
1152func (e *Editor) insertLineBelow() {
1153 b := e.activeBuffer()
1154 if b == nil {
1155 return
1156 }
1157 if b.readOnly {
1158 e.message = "File is read-only"
1159 return
1160 }
1161 line := b.buffer[b.PrimaryCursor().Y]
1162 indent := e.getIndentation(line)
1163
1164 // Check if the current line ends with '{' to increase indent
1165 trimmedLine := strings.TrimRight(string(line), " ")
1166 if len(trimmedLine) > 0 && trimmedLine[len(trimmedLine)-1] == '{' {
1167 if e.useTabs() {
1168 indent = append(indent, '\t')
1169 } else {
1170 tabWidth := Config.DefaultTabWidth
1171 if b.fileType != nil {
1172 tabWidth = b.fileType.TabWidth
1173 }
1174 indent = append(indent, []rune(strings.Repeat(" ", tabWidth))...)
1175 }
1176 }
1177
1178 newBuffer := make([][]rune, len(b.buffer)+1)
1179 copy(newBuffer[:b.PrimaryCursor().Y+1], b.buffer[:b.PrimaryCursor().Y+1])
1180 newBuffer[b.PrimaryCursor().Y+1] = indent
1181 copy(newBuffer[b.PrimaryCursor().Y+2:], b.buffer[b.PrimaryCursor().Y+1:])
1182 b.buffer = newBuffer
1183
1184 b.PrimaryCursor().Y++
1185 b.PrimaryCursor().X = len(indent)
1186
1187 if b.syntax != nil {
1188 insertedBytes := uint32(1 + len(string(indent)))
1189 oldLineLen := b.getLineByteOffset(line, len(line))
1190 b.handleEdit(b.PrimaryCursor().Y-1, len(line), 0, insertedBytes, b.PrimaryCursor().Y-1, oldLineLen, b.PrimaryCursor().Y, b.getLineByteOffset(b.buffer[b.PrimaryCursor().Y], b.PrimaryCursor().X))
1191 }
1192
1193 e.mode = ModeInsert
1194 if b.syntax != nil {
1195 b.syntax.Reparse([]byte(b.toString()))
1196 }
1197 e.markModified()
1198}
1199
1200func (e *Editor) insertLineAbove() {
1201 b := e.activeBuffer()
1202 if b == nil {
1203 return
1204 }
1205 if b.readOnly {
1206 e.message = "File is read-only"
1207 return
1208 }
1209 line := b.buffer[b.PrimaryCursor().Y]
1210 indent := e.getIndentation(line)
1211
1212 newBuffer := make([][]rune, len(b.buffer)+1)
1213 copy(newBuffer[:b.PrimaryCursor().Y], b.buffer[:b.PrimaryCursor().Y])
1214 newBuffer[b.PrimaryCursor().Y] = indent
1215 copy(newBuffer[b.PrimaryCursor().Y+1:], b.buffer[b.PrimaryCursor().Y:])
1216 b.buffer = newBuffer
1217
1218 b.PrimaryCursor().X = len(indent)
1219
1220 if b.syntax != nil {
1221 insertedBytes := uint32(1 + len(string(indent)))
1222 b.handleEdit(b.PrimaryCursor().Y, 0, 0, insertedBytes, b.PrimaryCursor().Y, 0, b.PrimaryCursor().Y+1, 0)
1223 }
1224
1225 e.mode = ModeInsert
1226 if b.syntax != nil {
1227 b.syntax.Reparse([]byte(b.toString()))
1228 }
1229 e.markModified()
1230}
1231
1232func (e *Editor) moveCursor(dx int, dy int) {
1233 b := e.activeBuffer()
1234 if b == nil {
1235 return
1236 }
1237
1238 for i := range b.cursors {
1239 c := &b.cursors[i]
1240 if dy != 0 {
1241 newY := c.Y + dy
1242 if newY >= 0 && newY < len(b.buffer) {
1243 c.Y = newY
1244 // Snap cursorX to the end of the new line if it's currently further
1245 // Or restore to preferred column if moving vertically
1246 if c.PreferredCol > len(b.buffer[c.Y]) {
1247 c.X = len(b.buffer[c.Y])
1248 } else {
1249 c.X = c.PreferredCol
1250 }
1251 }
1252 }
1253
1254 if dx != 0 {
1255 newX := c.X + dx
1256 if newX < 0 {
1257 if c.Y > 0 {
1258 c.Y--
1259 c.X = len(b.buffer[c.Y])
1260 }
1261 } else if newX > len(b.buffer[c.Y]) {
1262 if c.Y < len(b.buffer)-1 {
1263 c.Y++
1264 c.X = 0
1265 }
1266 } else {
1267 c.X = newX
1268 }
1269 // Update preferred column when moving horizontally
1270 c.PreferredCol = c.X
1271 }
1272 }
1273 // TODO: Merge overlapping cursors
1274}
1275
1276func (e *Editor) mergeCursors() {
1277 b := e.activeBuffer()
1278 if b == nil || len(b.cursors) <= 1 {
1279 return
1280 }
1281
1282 // Sort cursors by Y, then X
1283 sort.Slice(b.cursors, func(i, j int) bool {
1284 if b.cursors[i].Y != b.cursors[j].Y {
1285 return b.cursors[i].Y < b.cursors[j].Y
1286 }
1287 return b.cursors[i].X < b.cursors[j].X
1288 })
1289
1290 // Remove duplicates
1291 uniqueCursors := []Cursor{b.cursors[0]}
1292 for i := 1; i < len(b.cursors); i++ {
1293 current := b.cursors[i]
1294 last := uniqueCursors[len(uniqueCursors)-1]
1295
1296 if current.Y == last.Y && current.X == last.X {
1297 continue
1298 }
1299 uniqueCursors = append(uniqueCursors, current)
1300 }
1301 b.cursors = uniqueCursors
1302}
1303
1304func (e *Editor) isWordChar(r rune) bool {
1305 return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_'
1306}
1307
1308func (e *Editor) getWordUnderCursor() string {
1309 b := e.activeBuffer()
1310 if b == nil || len(b.buffer) == 0 {
1311 return ""
1312 }
1313 line := b.buffer[b.PrimaryCursor().Y]
1314 if len(line) == 0 || b.PrimaryCursor().X >= len(line) {
1315 return ""
1316 }
1317
1318 if !e.isWordChar(line[b.PrimaryCursor().X]) {
1319 return ""
1320 }
1321
1322 start := b.PrimaryCursor().X
1323 for start > 0 && e.isWordChar(line[start-1]) {
1324 start--
1325 }
1326
1327 end := b.PrimaryCursor().X
1328 for end < len(line) && e.isWordChar(line[end]) {
1329 end++
1330 }
1331
1332 return string(line[start:end])
1333}
1334
1335func (e *Editor) isPathChar(r rune) bool {
1336 return e.isWordChar(r) || r == '/' || r == '.' || r == '-' || r == '_' || r == '~' || r == '\\' || r == ':'
1337}
1338
1339func (e *Editor) getPathUnderCursor() string {
1340 b := e.activeBuffer()
1341 if b == nil || len(b.buffer) == 0 {
1342 return ""
1343 }
1344 line := b.buffer[b.PrimaryCursor().Y]
1345 if len(line) == 0 || b.PrimaryCursor().X >= len(line) {
1346 return ""
1347 }
1348
1349 if !e.isPathChar(line[b.PrimaryCursor().X]) {
1350 return ""
1351 }
1352
1353 // Start searching from the current cursor position
1354 start := b.PrimaryCursor().X
1355 for start > 0 && e.isPathChar(line[start-1]) {
1356 start--
1357 }
1358
1359 end := b.PrimaryCursor().X
1360 for end < len(line) && e.isPathChar(line[end]) {
1361 end++
1362 }
1363
1364 return string(line[start:end])
1365}
1366
1367func (e *Editor) gotoFile() {
1368 path := e.getPathUnderCursor()
1369 if path == "" {
1370 e.message = "No path under cursor"
1371 return
1372 }
1373
1374 // Check if the path is a URL
1375 if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
1376 e.openURL(path)
1377 return
1378 }
1379
1380 b := e.activeBuffer()
1381 if b == nil {
1382 return
1383 }
1384
1385 // Try relative to current file
1386 dir := filepath.Dir(b.filename)
1387 targetPath := filepath.Join(dir, path)
1388
1389 if _, err := os.Stat(targetPath); os.IsNotExist(err) {
1390 // Try relative to CWD
1391 targetPath = path
1392 if _, err := os.Stat(targetPath); os.IsNotExist(err) {
1393 e.message = "File not found: " + path
1394 return
1395 }
1396 }
1397
1398 // Resolve absolute path for comparison
1399 absPath, err := filepath.Abs(targetPath)
1400 if err != nil {
1401 e.message = "Error resolving path: " + err.Error()
1402 return
1403 }
1404
1405 // Check if already open
1406 for i, buf := range e.buffers {
1407 bufAbs, _ := filepath.Abs(buf.filename)
1408 if absPath == bufAbs {
1409 e.pushJump()
1410 e.activeBufferIndex = i
1411 return
1412 }
1413 }
1414
1415 // Open new file
1416 e.pushJump()
1417 if err := e.LoadFile(targetPath); err != nil {
1418 e.message = "Error opening file: " + err.Error()
1419 }
1420}
1421
1422func (e *Editor) openURL(url string) {
1423 var cmd string
1424 var args []string
1425
1426 // Detect OS and set appropriate command
1427 switch runtime.GOOS {
1428 case "darwin":
1429 // macOS
1430 cmd = "open"
1431 args = []string{url}
1432 default:
1433 // Linux and others
1434 cmd = "xdg-open"
1435 args = []string{url}
1436 }
1437
1438 // Execute the command
1439 exec := exec.Command(cmd, args...)
1440 if err := exec.Start(); err != nil {
1441 e.message = "Error opening URL: " + err.Error()
1442 } else {
1443 e.message = "Opening URL in browser..."
1444 }
1445}
1446
1447// centerCursor scrolls the viewport so the cursor is in the middle of the screen.
1448func (e *Editor) centerCursor() {
1449 b := e.activeBuffer()
1450 if b == nil {
1451 return
1452 }
1453
1454 _, h := termbox.Size()
1455 visibleHeight := h - 2 // Status bar and message line.
1456 if visibleHeight < 1 {
1457 visibleHeight = 1
1458 }
1459
1460 targetScrollY := b.PrimaryCursor().Y - (visibleHeight / 2)
1461 if targetScrollY < 0 {
1462 targetScrollY = 0
1463 }
1464
1465 // Clamp to legitimate buffer range.
1466 if targetScrollY > len(b.buffer)-visibleHeight {
1467 targetScrollY = len(b.buffer) - visibleHeight
1468 }
1469 if targetScrollY < 0 {
1470 targetScrollY = 0
1471 }
1472
1473 b.scrollY = targetScrollY
1474}
1475
1476func (e *Editor) gotoDefinition() {
1477 b := e.activeBuffer()
1478 if b == nil || b.lspClient == nil {
1479 return
1480 }
1481
1482 e.pushJump()
1483
1484 locs, err := b.lspClient.Definition(b.PrimaryCursor().Y, b.PrimaryCursor().X)
1485 if err != nil {
1486 e.addLog("Editor", fmt.Sprintf("gotoDefinition error: %v", err))
1487 return
1488 }
1489
1490 if len(locs) == 0 {
1491 e.addLog("Editor", "gotoDefinition: No definition found")
1492 return
1493 }
1494
1495 loc := locs[0]
1496 targetPath := strings.TrimPrefix(loc.URI, "file://")
1497
1498 // Find if buffer is already open
1499 found := false
1500 for i, buf := range e.buffers {
1501 absT, _ := filepath.Abs(targetPath)
1502 absB, _ := filepath.Abs(buf.filename)
1503 if absT == absB {
1504 e.activeBufferIndex = i
1505 found = true
1506 break
1507 }
1508 }
1509
1510 if !found {
1511 if err := e.LoadFile(targetPath); err != nil {
1512 e.addLog("Editor", fmt.Sprintf("gotoDefinition: Failed to load %s: %v", targetPath, err))
1513 return
1514 }
1515 }
1516
1517 b = e.activeBuffer()
1518 b.PrimaryCursor().Y = loc.Range.Start.Line
1519 b.PrimaryCursor().X = loc.Range.Start.Character
1520
1521 // Ensure cursor is within bounds
1522 if b.PrimaryCursor().Y < 0 {
1523 b.PrimaryCursor().Y = 0
1524 }
1525 if b.PrimaryCursor().Y >= len(b.buffer) {
1526 b.PrimaryCursor().Y = len(b.buffer) - 1
1527 }
1528 if b.PrimaryCursor().X < 0 {
1529 b.PrimaryCursor().X = 0
1530 }
1531 if b.PrimaryCursor().X > len(b.buffer[b.PrimaryCursor().Y]) {
1532 b.PrimaryCursor().X = len(b.buffer[b.PrimaryCursor().Y])
1533 }
1534 e.centerCursor()
1535}
1536
1537func (e *Editor) pushJump() {
1538 b := e.activeBuffer()
1539 if b == nil {
1540 return
1541 }
1542
1543 jump := Jump{
1544 filename: b.filename,
1545 cursorX: b.PrimaryCursor().X,
1546 cursorY: b.PrimaryCursor().Y,
1547 }
1548
1549 // If we're not at the end of the jumplist, truncate it
1550 if e.jumpIndex < len(e.jumplist)-1 {
1551 e.jumplist = e.jumplist[:e.jumpIndex+1]
1552 }
1553
1554 // Don't push if the last jump is the same position
1555 if len(e.jumplist) > 0 {
1556 last := e.jumplist[len(e.jumplist)-1]
1557 if last.filename == jump.filename && last.cursorX == jump.cursorX && last.cursorY == jump.cursorY {
1558 return
1559 }
1560 }
1561
1562 e.jumplist = append(e.jumplist, jump)
1563 if len(e.jumplist) > 100 {
1564 e.jumplist = e.jumplist[1:]
1565 }
1566 e.jumpIndex = len(e.jumplist) - 1
1567}
1568
1569func (e *Editor) jumpBack() {
1570 if e.jumpIndex < 0 {
1571 return
1572 }
1573
1574 // If we are at the latest jump, push the CURRENT position so we can return to it
1575 if e.jumpIndex == len(e.jumplist)-1 {
1576 b := e.activeBuffer()
1577 if b != nil {
1578 curr := Jump{filename: b.filename, cursorX: b.PrimaryCursor().X, cursorY: b.PrimaryCursor().Y}
1579 last := e.jumplist[e.jumpIndex]
1580 if curr != last {
1581 e.jumplist = append(e.jumplist, curr)
1582 e.jumpIndex = len(e.jumplist) - 2 // Point to the one before the one we just added
1583 } else {
1584 e.jumpIndex--
1585 }
1586 } else {
1587 e.jumpIndex--
1588 }
1589 } else {
1590 e.jumpIndex--
1591 }
1592
1593 if e.jumpIndex < 0 {
1594 return
1595 }
1596
1597 e.performJump(e.jumplist[e.jumpIndex])
1598}
1599
1600func (e *Editor) jumpForward() {
1601 if e.jumpIndex >= len(e.jumplist)-1 {
1602 return
1603 }
1604
1605 e.jumpIndex++
1606 e.performJump(e.jumplist[e.jumpIndex])
1607}
1608
1609func (e *Editor) performJump(jump Jump) {
1610 // Find if buffer is already open
1611 found := false
1612 for i, buf := range e.buffers {
1613 absT, _ := filepath.Abs(jump.filename)
1614 absB, _ := filepath.Abs(buf.filename)
1615 if absT == absB {
1616 e.activeBufferIndex = i
1617 found = true
1618 break
1619 }
1620 }
1621
1622 if !found {
1623 if err := e.LoadFile(jump.filename); err != nil {
1624 e.addLog("Editor", fmt.Sprintf("performJump: Failed to load %s: %v", jump.filename, err))
1625 return
1626 }
1627 }
1628
1629 b := e.activeBuffer()
1630 b.PrimaryCursor().Y = jump.cursorY
1631 b.PrimaryCursor().X = jump.cursorX
1632
1633 // Ensure cursor is within bounds
1634 if b.PrimaryCursor().Y < 0 {
1635 b.PrimaryCursor().Y = 0
1636 }
1637 if b.PrimaryCursor().Y >= len(b.buffer) {
1638 b.PrimaryCursor().Y = len(b.buffer) - 1
1639 }
1640 if b.PrimaryCursor().X < 0 {
1641 b.PrimaryCursor().X = 0
1642 }
1643 if b.PrimaryCursor().X > len(b.buffer[b.PrimaryCursor().Y]) {
1644 b.PrimaryCursor().X = len(b.buffer[b.PrimaryCursor().Y])
1645 }
1646}
1647
1648// deleteWord removes a word-clump from the current cursor position.
1649func (e *Editor) deleteWord(includeSpaces bool) {
1650 b := e.activeBuffer()
1651 if b == nil || len(b.buffer) == 0 {
1652 return
1653 }
1654 if b.readOnly {
1655 e.message = "File is read-only"
1656 return
1657 }
1658
1659 cursors := e.getSortedCursorsDesc()
1660 for _, c := range cursors {
1661 if c.Y >= len(b.buffer) {
1662 continue
1663 }
1664 line := b.buffer[c.Y]
1665 if len(line) == 0 || c.X >= len(line) {
1666 continue
1667 }
1668
1669 start := c.X
1670 end := start
1671
1672 // Determine the boundary of the deletion based on character type.
1673 if e.isWordChar(line[end]) {
1674 // On a word character: skip word characters, then skip trailing spaces
1675 for end < len(line) && e.isWordChar(line[end]) {
1676 end++
1677 }
1678 if includeSpaces {
1679 for end < len(line) && (line[end] == ' ' || line[end] == '\t') {
1680 end++
1681 }
1682 }
1683 } else if line[end] == ' ' || line[end] == '\t' {
1684 // On whitespace: skip all leading whitespace
1685 for end < len(line) && (line[end] == ' ' || line[end] == '\t') {
1686 end++
1687 }
1688 } else {
1689 // On punctuation/other: skip those, then skip trailing spaces
1690 for end < len(line) && !e.isWordChar(line[end]) && line[end] != ' ' && line[end] != '\t' {
1691 end++
1692 }
1693 if includeSpaces {
1694 for end < len(line) && (line[end] == ' ' || line[end] == '\t') {
1695 end++
1696 }
1697 }
1698 }
1699
1700 // Copy to clipboard (only for primary cursor)
1701 if c == b.PrimaryCursor() {
1702 e.clipboard = make([]rune, end-start)
1703 copy(e.clipboard, line[start:end])
1704 }
1705
1706 // Delete from start to end
1707 newLine := append(line[:start], line[end:]...)
1708 b.buffer[c.Y] = newLine
1709
1710 // Ensure cursor is within bounds
1711 if c.X >= len(b.buffer[c.Y]) {
1712 c.X = len(b.buffer[c.Y])
1713 if c.X < 0 {
1714 c.X = 0
1715 }
1716 }
1717
1718 // Handle syntax update
1719 if b.syntax != nil {
1720 deletedBytes := uint32(len(string(line[start:end])))
1721 oldColBytes := b.getLineByteOffset(line, start)
1722 newColBytes := b.getLineByteOffset(newLine, start)
1723 b.handleEdit(c.Y, start, deletedBytes, 0, c.Y, oldColBytes+deletedBytes, c.Y, newColBytes)
1724 }
1725 }
1726
1727 if b.syntax != nil {
1728 b.syntax.Reparse([]byte(b.toString()))
1729 }
1730 e.markModified()
1731}
1732
1733func (e *Editor) deleteWordBackward() {
1734 b := e.activeBuffer()
1735 if b == nil || len(b.buffer) == 0 || b.PrimaryCursor().Y >= len(b.buffer) {
1736 return
1737 }
1738 if b.readOnly {
1739 e.message = "File is read-only"
1740 return
1741 }
1742 line := b.buffer[b.PrimaryCursor().Y]
1743 if len(line) == 0 || b.PrimaryCursor().X == 0 {
1744 return
1745 }
1746
1747 end := b.PrimaryCursor().X
1748 start := end
1749
1750 // 1. Skip whitespace going back
1751 for start > 0 && (line[start-1] == ' ' || line[start-1] == '\t') {
1752 start--
1753 }
1754
1755 // 2. Determine type of character before whitespace (or at cursor if no whitespace)
1756 if start > 0 {
1757 r := line[start-1]
1758 if e.isWordChar(r) {
1759 // On word characters: skip word characters
1760 for start > 0 && e.isWordChar(line[start-1]) {
1761 start--
1762 }
1763 } else {
1764 // On punctuation/other: skip those
1765 for start > 0 && !e.isWordChar(line[start-1]) && line[start-1] != ' ' && line[start-1] != '\t' {
1766 start--
1767 }
1768 }
1769 }
1770
1771 // Delete from start to end
1772 newLine := append(line[:start], line[end:]...)
1773 b.buffer[b.PrimaryCursor().Y] = newLine
1774 b.PrimaryCursor().X = start
1775
1776 // Handle syntax update
1777 if b.syntax != nil {
1778 deletedBytes := uint32(len(string(line[start:end])))
1779 oldColBytes := b.getLineByteOffset(line, start)
1780 newColBytes := b.getLineByteOffset(newLine, start)
1781 b.handleEdit(b.PrimaryCursor().Y, start, deletedBytes, 0, b.PrimaryCursor().Y, oldColBytes+deletedBytes, b.PrimaryCursor().Y, newColBytes)
1782 }
1783
1784 if b.syntax != nil {
1785 b.syntax.Reparse([]byte(b.toString()))
1786 }
1787 e.markModified()
1788}
1789
1790// deleteWordBackwardFromBuffer removes the last word from the commandBuffer at cursor position.
1791func (e *Editor) deleteWordBackwardFromBuffer() {
1792 if e.commandCursorX == 0 {
1793 return
1794 }
1795
1796 start := e.commandCursorX
1797
1798 // Skip trailing whitespace.
1799 for start > 0 && (e.commandBuffer[start-1] == ' ' || e.commandBuffer[start-1] == '\t') {
1800 start--
1801 }
1802
1803 // Delete word characters or punctuation.
1804 if start > 0 {
1805 r := e.commandBuffer[start-1]
1806 if e.isWordChar(r) {
1807 // Delete word characters.
1808 for start > 0 && e.isWordChar(e.commandBuffer[start-1]) {
1809 start--
1810 }
1811 } else {
1812 // Delete punctuation/other characters.
1813 for start > 0 && !e.isWordChar(e.commandBuffer[start-1]) && e.commandBuffer[start-1] != ' ' && e.commandBuffer[start-1] != '\t' {
1814 start--
1815 }
1816 }
1817 }
1818
1819 // Remove the word from the buffer
1820 e.commandBuffer = append(e.commandBuffer[:start], e.commandBuffer[e.commandCursorX:]...)
1821 e.commandCursorX = start
1822}
1823
1824func (e *Editor) changeWord() {
1825 b := e.activeBuffer()
1826 if b != nil && b.readOnly {
1827 e.message = "File is read-only"
1828 return
1829 }
1830 e.deleteWord(false)
1831 e.mode = ModeInsert
1832}
1833
1834func (e *Editor) changeCharacter() {
1835 b := e.activeBuffer()
1836 if b != nil && b.readOnly {
1837 e.message = "File is read-only"
1838 return
1839 }
1840 e.DeleteChar()
1841 e.mode = ModeInsert
1842}
1843
1844func (e *Editor) deleteToEndOfLine() {
1845 b := e.activeBuffer()
1846 if b == nil {
1847 return
1848 }
1849 if b.readOnly {
1850 e.message = "File is read-only"
1851 return
1852 }
1853
1854 cursors := e.getSortedCursorsDesc()
1855 for _, c := range cursors {
1856 if c.Y >= len(b.buffer) {
1857 continue
1858 }
1859
1860 line := b.buffer[c.Y]
1861 if c.X >= len(line) {
1862 continue
1863 }
1864
1865 // Save deleted text of the primary cursor to the clipboard
1866 if c == b.PrimaryCursor() {
1867 deletedText := line[c.X:]
1868 e.clipboard = make([]rune, len(deletedText))
1869 copy(e.clipboard, deletedText)
1870 }
1871
1872 // Truncate the line at the cursor position
1873 deletedBytes := uint32(len(string(line[c.X:])))
1874 newLine := line[:c.X]
1875 b.buffer[c.Y] = newLine
1876
1877 // Handle syntax update
1878 if b.syntax != nil {
1879 oldColBytes := b.getLineByteOffset(line, c.X)
1880 newColBytes := b.getLineByteOffset(newLine, c.X)
1881 b.handleEdit(c.Y, c.X, deletedBytes, 0, c.Y, oldColBytes+deletedBytes, c.Y, newColBytes)
1882 }
1883 }
1884
1885 if b.syntax != nil {
1886 b.syntax.Reparse([]byte(b.toString()))
1887 }
1888 e.markModified()
1889}
1890
1891func (e *Editor) changeToEndOfLine() {
1892 b := e.activeBuffer()
1893 if b != nil && b.readOnly {
1894 e.message = "File is read-only"
1895 return
1896 }
1897 e.deleteToEndOfLine()
1898 e.mode = ModeInsert
1899}
1900
1901// deleteInside removes text within a pair of delimiters (e.g., "", (), {}).
1902func (e *Editor) deleteInside(open, close rune) bool {
1903 b := e.activeBuffer()
1904 if b == nil || len(b.buffer) == 0 {
1905 return false
1906 }
1907 if b.readOnly {
1908 e.message = "File is read-only"
1909 return false
1910 }
1911 line := b.buffer[b.PrimaryCursor().Y]
1912 if len(line) == 0 {
1913 return false
1914 }
1915
1916 type pair struct {
1917 start, end int
1918 }
1919 var pairs []pair
1920
1921 // Find all candidate delimiter pairs on the current line.
1922 if open == close {
1923 var indices []int
1924 for i, r := range line {
1925 if r == open {
1926 indices = append(indices, i)
1927 }
1928 }
1929 for i := 0; i+1 < len(indices); i += 2 {
1930 pairs = append(pairs, pair{indices[i], indices[i+1]})
1931 }
1932 } else {
1933 var stack []int
1934 for i, r := range line {
1935 if r == open {
1936 stack = append(stack, i)
1937 } else if r == close {
1938 if len(stack) > 0 {
1939 start := stack[len(stack)-1]
1940 stack = stack[:len(stack)-1]
1941 pairs = append(pairs, pair{start, i})
1942 }
1943 }
1944 }
1945 }
1946
1947 // Find the smallest pair that strictly contains the cursor.
1948 var bestPair *pair
1949 for i := range pairs {
1950 p := &pairs[i]
1951 if b.PrimaryCursor().X >= p.start && b.PrimaryCursor().X <= p.end {
1952 if bestPair == nil || (p.start > bestPair.start) {
1953 bestPair = p
1954 }
1955 }
1956 }
1957
1958 if bestPair == nil {
1959 for i := range pairs {
1960 p := &pairs[i]
1961 if p.start >= b.PrimaryCursor().X {
1962 if bestPair == nil || p.start < bestPair.start {
1963 bestPair = p
1964 }
1965 }
1966 }
1967 }
1968
1969 if bestPair != nil && bestPair.end > bestPair.start+1 {
1970 start := bestPair.start
1971 end := bestPair.end
1972 deletedChars := line[start+1 : end]
1973 deletedBytes := uint32(len(string(deletedChars)))
1974
1975 newLine := append(line[:start+1], line[end:]...)
1976 b.buffer[b.PrimaryCursor().Y] = newLine
1977 b.PrimaryCursor().X = start + 1
1978
1979 if b.syntax != nil {
1980 oldColBytes := b.getLineByteOffset(line, start+1)
1981 newColBytes := b.getLineByteOffset(newLine, start+1)
1982 b.handleEdit(b.PrimaryCursor().Y, start+1, deletedBytes, 0, b.PrimaryCursor().Y, oldColBytes+deletedBytes, b.PrimaryCursor().Y, newColBytes)
1983 }
1984 if b.syntax != nil {
1985 b.syntax.Reparse([]byte(b.toString()))
1986 }
1987 e.markModified()
1988 return true
1989 }
1990 return false
1991}
1992
1993func (e *Editor) changeInside(open, close rune) {
1994 if e.deleteInside(open, close) {
1995 e.mode = ModeInsert
1996 }
1997}
1998
1999func (e *Editor) moveWordForward() {
2000 b := e.activeBuffer()
2001 if b == nil || len(b.buffer) == 0 {
2002 return
2003 }
2004
2005 // Helper to get char type: 0=space, 1=word, 2=punct
2006 getType := func(r rune) int {
2007 if r == ' ' || r == '\t' {
2008 return 0
2009 }
2010 if e.isWordChar(r) {
2011 return 1
2012 }
2013 return 2
2014 }
2015
2016 // Process each cursor independently
2017 for i := range b.cursors {
2018 cursor := &b.cursors[i]
2019
2020 if cursor.Y >= len(b.buffer) {
2021 cursor.Y = len(b.buffer) - 1
2022 }
2023
2024 currentLine := b.buffer[cursor.Y]
2025
2026 // 1. Skip current word/punct clump
2027 if cursor.X < len(currentLine) {
2028 startType := getType(currentLine[cursor.X])
2029 if startType != 0 {
2030 for cursor.X < len(currentLine) {
2031 if getType(currentLine[cursor.X]) != startType {
2032 break
2033 }
2034 cursor.X++
2035 }
2036 }
2037 }
2038
2039 // 2. Skip whitespace
2040 for {
2041 // If at end of line, move to next line
2042 if cursor.X >= len(b.buffer[cursor.Y]) {
2043 if cursor.Y < len(b.buffer)-1 {
2044 cursor.Y++
2045 cursor.X = 0
2046 // Continue loop to check new line content
2047 } else {
2048 break // End of file
2049 }
2050 }
2051
2052 line := b.buffer[cursor.Y]
2053 if len(line) == 0 {
2054 // Empty line, continue to next
2055 if cursor.Y < len(b.buffer)-1 {
2056 // We need to advance line manually here if we are on empty line
2057 // but only if we haven't just moved to it (which is handled by loop re-entry)
2058 // Actually, the check at top of loop handles line length check.
2059 // If line is empty, len is 0.
2060 // We just need to check if we are stuck.
2061 // If we are at X=0 on empty line, we should move to next line.
2062
2063 // Let's rely on the loop condition:
2064 // if X >= len, it moves to next line.
2065 // if line is empty, len is 0. So X=0 >= 0 is true.
2066 // So it moves to next line immediately.
2067 // But we need to break if we found a word? No, empty line is not a word start.
2068 // So we continue.
2069 } else {
2070 break // EOF
2071 }
2072 } else {
2073 c := line[cursor.X]
2074 if c == ' ' || c == '\t' {
2075 cursor.X++
2076 continue
2077 }
2078
2079 // Found start of next word
2080 break
2081 }
2082 }
2083
2084 // Update preferred column
2085 cursor.PreferredCol = cursor.X
2086 }
2087
2088 e.mergeCursors()
2089}
2090
2091func (e *Editor) moveWordBackward() {
2092 b := e.activeBuffer()
2093 if b == nil || len(b.buffer) == 0 {
2094 return
2095 }
2096
2097 for i := range b.cursors {
2098 cursor := &b.cursors[i]
2099
2100 // Helper to step back one char, wrapping lines
2101 stepBack := func() bool {
2102 if cursor.X > 0 {
2103 cursor.X--
2104 return true
2105 }
2106 if cursor.Y > 0 {
2107 cursor.Y--
2108 cursor.X = len(b.buffer[cursor.Y])
2109 if cursor.X > 0 {
2110 cursor.X--
2111 }
2112 return true
2113 }
2114 return false // Start of file
2115 }
2116
2117 // 1. Move back 1 char initially
2118 if !stepBack() {
2119 continue
2120 }
2121
2122 // 2. Skip whitespace going back
2123 for {
2124 line := b.buffer[cursor.Y]
2125 if len(line) == 0 {
2126 if !stepBack() {
2127 break
2128 }
2129 continue
2130 }
2131
2132 c := line[cursor.X]
2133 if c == ' ' || c == '\t' {
2134 if !stepBack() {
2135 break
2136 }
2137 continue
2138 }
2139 break
2140 }
2141
2142 // 3. We are on last char of a "word". Go to its start.
2143 line := b.buffer[cursor.Y]
2144 getType := func(r rune) int {
2145 if e.isWordChar(r) {
2146 return 1
2147 }
2148 return 2
2149 }
2150
2151 if cursor.X < len(line) {
2152 targetType := getType(line[cursor.X])
2153
2154 for cursor.X > 0 {
2155 prev := line[cursor.X-1]
2156 if prev == ' ' || prev == '\t' {
2157 break
2158 }
2159 if getType(prev) != targetType {
2160 break
2161 }
2162 cursor.X--
2163 }
2164 }
2165
2166 cursor.PreferredCol = cursor.X
2167 }
2168
2169 e.mergeCursors()
2170}
2171
2172// deleteLine removes the current line and saves it to the clipboard.
2173func (e *Editor) deleteLine() {
2174 b := e.activeBuffer()
2175 if b == nil || len(b.buffer) == 0 {
2176 return
2177 }
2178 if b.readOnly {
2179 e.message = "File is read-only"
2180 return
2181 }
2182
2183 line := b.buffer[b.PrimaryCursor().Y]
2184 e.clipboard = make([]rune, len(line)+1)
2185 copy(e.clipboard, line)
2186 e.clipboard[len(line)] = '\n'
2187
2188 if len(b.buffer) == 1 {
2189 lineLen := uint32(len(string(b.buffer[0])))
2190 b.buffer[0] = []rune{}
2191 b.PrimaryCursor().X = 0
2192
2193 if b.syntax != nil {
2194 b.handleEdit(0, 0, lineLen, 0, 0, lineLen, 0, 0)
2195 }
2196 } else {
2197 lineLen := uint32(len(string(b.buffer[b.PrimaryCursor().Y]))) + 1
2198 b.buffer = append(b.buffer[:b.PrimaryCursor().Y], b.buffer[b.PrimaryCursor().Y+1:]...)
2199
2200 if b.syntax != nil {
2201 b.handleEdit(b.PrimaryCursor().Y, 0, lineLen, 0, b.PrimaryCursor().Y+1, 0, b.PrimaryCursor().Y, 0)
2202 }
2203
2204 if b.PrimaryCursor().Y >= len(b.buffer) {
2205 b.PrimaryCursor().Y = len(b.buffer) - 1
2206 }
2207 b.PrimaryCursor().X = 0
2208 }
2209 if b.syntax != nil {
2210 b.syntax.Reparse([]byte(b.toString()))
2211 }
2212 e.markModified()
2213}
2214
2215func (e *Editor) yankLine() {
2216 b := e.activeBuffer()
2217 if b == nil || len(b.buffer) == 0 {
2218 return
2219 }
2220 line := b.buffer[b.PrimaryCursor().Y]
2221 e.clipboard = make([]rune, len(line)+1)
2222 copy(e.clipboard, line)
2223 e.clipboard[len(line)] = '\n'
2224}
2225
2226func (e *Editor) pasteLine() {
2227 b := e.activeBuffer()
2228 if b == nil || len(e.clipboard) == 0 {
2229 return
2230 }
2231 if b.readOnly {
2232 e.message = "File is read-only"
2233 return
2234 }
2235
2236 isLineWise := e.clipboard[len(e.clipboard)-1] == '\n'
2237
2238 if isLineWise {
2239 content := e.clipboard[:len(e.clipboard)-1]
2240 parts := strings.Split(string(content), "\n")
2241 count := len(parts)
2242
2243 newBuffer := make([][]rune, len(b.buffer)+count)
2244 copy(newBuffer[:b.PrimaryCursor().Y+1], b.buffer[:b.PrimaryCursor().Y+1])
2245
2246 for i, part := range parts {
2247 newBuffer[b.PrimaryCursor().Y+1+i] = []rune(part)
2248 }
2249
2250 copy(newBuffer[b.PrimaryCursor().Y+1+count:], b.buffer[b.PrimaryCursor().Y+1:])
2251 b.buffer = newBuffer
2252
2253 b.PrimaryCursor().Y += count
2254 b.PrimaryCursor().X = 0
2255 } else {
2256 // Character-wise: paste after cursor
2257 fullText := string(e.clipboard)
2258 parts := strings.Split(fullText, "\n")
2259
2260 if len(parts) == 1 {
2261 line := b.buffer[b.PrimaryCursor().Y]
2262 at := b.PrimaryCursor().X
2263 if len(line) > 0 {
2264 at++
2265 }
2266 if at > len(line) {
2267 at = len(line)
2268 }
2269
2270 newLine := make([]rune, len(line)+len(e.clipboard))
2271 copy(newLine[:at], line[:at])
2272 copy(newLine[at:], e.clipboard)
2273 copy(newLine[at+len(e.clipboard):], line[at:])
2274 b.buffer[b.PrimaryCursor().Y] = newLine
2275 b.PrimaryCursor().X = at + len(e.clipboard) - 1
2276 if b.PrimaryCursor().X < 0 {
2277 b.PrimaryCursor().X = 0
2278 }
2279 } else {
2280 // Multi-line character-wise paste after cursor
2281 line := b.buffer[b.PrimaryCursor().Y]
2282 at := b.PrimaryCursor().X
2283 if len(line) > 0 {
2284 at++
2285 }
2286 if at > len(line) {
2287 at = len(line)
2288 }
2289
2290 prefix := line[:at]
2291 suffix := line[at:]
2292
2293 newLines := make([][]rune, len(parts))
2294 newLines[0] = append([]rune(nil), prefix...)
2295 newLines[0] = append(newLines[0], []rune(parts[0])...)
2296
2297 for i := 1; i < len(parts)-1; i++ {
2298 newLines[i] = []rune(parts[i])
2299 }
2300
2301 lastIndex := len(parts) - 1
2302 newLines[lastIndex] = []rune(parts[lastIndex])
2303 newLines[lastIndex] = append(newLines[lastIndex], suffix...)
2304
2305 // Insert into buffer
2306 newBuffer := make([][]rune, len(b.buffer)+len(parts)-1)
2307 copy(newBuffer[:b.PrimaryCursor().Y], b.buffer[:b.PrimaryCursor().Y])
2308 copy(newBuffer[b.PrimaryCursor().Y:b.PrimaryCursor().Y+len(parts)], newLines)
2309 copy(newBuffer[b.PrimaryCursor().Y+len(parts):], b.buffer[b.PrimaryCursor().Y+1:])
2310 b.buffer = newBuffer
2311
2312 // Move cursor to end of pasted text
2313 b.PrimaryCursor().Y = b.PrimaryCursor().Y + len(parts) - 1
2314 b.PrimaryCursor().X = len([]rune(parts[lastIndex]))
2315 }
2316 }
2317 e.markModified()
2318
2319 if b.syntax != nil {
2320 b.syntax.Parse([]byte(b.toString()))
2321 }
2322}
2323
2324func (e *Editor) pasteLineAbove() {
2325 b := e.activeBuffer()
2326 if b == nil || len(e.clipboard) == 0 {
2327 return
2328 }
2329 if b.readOnly {
2330 e.message = "File is read-only"
2331 return
2332 }
2333
2334 isLineWise := e.clipboard[len(e.clipboard)-1] == '\n'
2335
2336 if isLineWise {
2337 content := e.clipboard[:len(e.clipboard)-1]
2338 parts := strings.Split(string(content), "\n")
2339 count := len(parts)
2340
2341 newBuffer := make([][]rune, len(b.buffer)+count)
2342 copy(newBuffer[:b.PrimaryCursor().Y], b.buffer[:b.PrimaryCursor().Y])
2343
2344 for i, part := range parts {
2345 newBuffer[b.PrimaryCursor().Y+i] = []rune(part)
2346 }
2347
2348 copy(newBuffer[b.PrimaryCursor().Y+count:], b.buffer[b.PrimaryCursor().Y:])
2349 b.buffer = newBuffer
2350
2351 b.PrimaryCursor().X = 0
2352 } else {
2353 // Character-wise: paste at cursor
2354 // Handle potential newlines in character-wise clipboard (e.g. from visual selection)
2355 fullText := string(e.clipboard)
2356 parts := strings.Split(fullText, "\n")
2357
2358 if len(parts) == 1 {
2359 // Single line character-wise paste
2360 line := b.buffer[b.PrimaryCursor().Y]
2361 at := b.PrimaryCursor().X
2362 if at > len(line) {
2363 at = len(line)
2364 }
2365
2366 newLine := make([]rune, len(line)+len(e.clipboard))
2367 copy(newLine[:at], line[:at])
2368 copy(newLine[at:], e.clipboard)
2369 copy(newLine[at+len(e.clipboard):], line[at:])
2370 b.buffer[b.PrimaryCursor().Y] = newLine
2371 b.PrimaryCursor().X = at + len(e.clipboard) - 1
2372 if b.PrimaryCursor().X < 0 {
2373 b.PrimaryCursor().X = 0
2374 }
2375 } else {
2376 // Multi-line character-wise paste
2377 line := b.buffer[b.PrimaryCursor().Y]
2378 prefix := line[:b.PrimaryCursor().X]
2379 suffix := line[b.PrimaryCursor().X:]
2380
2381 newLines := make([][]rune, len(parts))
2382 newLines[0] = append([]rune(nil), prefix...)
2383 newLines[0] = append(newLines[0], []rune(parts[0])...)
2384
2385 for i := 1; i < len(parts)-1; i++ {
2386 newLines[i] = []rune(parts[i])
2387 }
2388
2389 lastIndex := len(parts) - 1
2390 newLines[lastIndex] = []rune(parts[lastIndex])
2391 newLines[lastIndex] = append(newLines[lastIndex], suffix...)
2392
2393 // Insert into buffer
2394 newBuffer := make([][]rune, len(b.buffer)+len(parts)-1)
2395 copy(newBuffer[:b.PrimaryCursor().Y], b.buffer[:b.PrimaryCursor().Y])
2396 copy(newBuffer[b.PrimaryCursor().Y:b.PrimaryCursor().Y+len(parts)], newLines)
2397 copy(newBuffer[b.PrimaryCursor().Y+len(parts):], b.buffer[b.PrimaryCursor().Y+1:])
2398 b.buffer = newBuffer
2399
2400 // Move cursor to end of pasted text
2401 b.PrimaryCursor().Y = b.PrimaryCursor().Y + len(parts) - 1
2402 b.PrimaryCursor().X = len([]rune(parts[lastIndex]))
2403 }
2404 }
2405 e.markModified()
2406
2407 if b.syntax != nil {
2408 b.syntax.Parse([]byte(b.toString()))
2409 }
2410}
2411
2412func (e *Editor) duplicateLine() {
2413 b := e.activeBuffer()
2414 if b == nil || len(b.buffer) == 0 {
2415 return
2416 }
2417 if b.readOnly {
2418 e.message = "File is read-only"
2419 return
2420 }
2421
2422 line := make([]rune, len(b.buffer[b.PrimaryCursor().Y]))
2423 copy(line, b.buffer[b.PrimaryCursor().Y])
2424
2425 newBuffer := make([][]rune, len(b.buffer)+1)
2426 copy(newBuffer[:b.PrimaryCursor().Y+1], b.buffer[:b.PrimaryCursor().Y+1])
2427 newBuffer[b.PrimaryCursor().Y+1] = line
2428 copy(newBuffer[b.PrimaryCursor().Y+2:], b.buffer[b.PrimaryCursor().Y+1:])
2429 b.buffer = newBuffer
2430
2431 b.PrimaryCursor().Y++
2432 e.markModified()
2433
2434 if b.syntax != nil {
2435 b.syntax.Parse([]byte(b.toString()))
2436 }
2437}
2438
2439func (e *Editor) jumpToPrevEmptyLine() {
2440 e.pushJump()
2441 b := e.activeBuffer()
2442 if b == nil {
2443 return
2444 }
2445 // Search backwards from current line for an empty line
2446 for y := b.PrimaryCursor().Y - 1; y >= 0; y-- {
2447 if len(b.buffer[y]) == 0 {
2448 b.PrimaryCursor().Y = y
2449 b.PrimaryCursor().X = 0
2450 return
2451 }
2452 }
2453 e.jumpToTop()
2454}
2455
2456func (e *Editor) jumpToNextEmptyLine() {
2457 e.pushJump()
2458 b := e.activeBuffer()
2459 if b == nil {
2460 return
2461 }
2462 // Search forwards from current line for an empty line
2463 for y := b.PrimaryCursor().Y + 1; y < len(b.buffer); y++ {
2464 if len(b.buffer[y]) == 0 {
2465 b.PrimaryCursor().Y = y
2466 b.PrimaryCursor().X = 0
2467 return
2468 }
2469 }
2470 e.jumpToBottom()
2471}
2472
2473func (e *Editor) jumpToTop() {
2474 e.pushJump()
2475 b := e.activeBuffer()
2476 if b == nil {
2477 return
2478 }
2479 b.PrimaryCursor().Y = 0
2480 b.PrimaryCursor().X = 0
2481}
2482
2483func (e *Editor) jumpToBottom() {
2484 e.pushJump()
2485 b := e.activeBuffer()
2486 if b == nil {
2487 return
2488 }
2489 b.PrimaryCursor().Y = len(b.buffer) - 1
2490 if b.PrimaryCursor().Y < 0 {
2491 b.PrimaryCursor().Y = 0
2492 }
2493 b.PrimaryCursor().X = 0
2494}
2495
2496func (e *Editor) jumpToLineEnd() {
2497 b := e.activeBuffer()
2498 if b == nil || len(b.buffer) == 0 {
2499 return
2500 }
2501 b.PrimaryCursor().X = len(b.buffer[b.PrimaryCursor().Y])
2502}
2503
2504func (e *Editor) jumpToLineStart() {
2505 b := e.activeBuffer()
2506 if b == nil || len(b.buffer) == 0 {
2507 return
2508 }
2509 b.PrimaryCursor().X = 0
2510}
2511
2512func (e *Editor) jumpToFirstNonBlank() {
2513 b := e.activeBuffer()
2514 if b == nil || len(b.buffer) == 0 {
2515 return
2516 }
2517 line := b.buffer[b.PrimaryCursor().Y]
2518 b.PrimaryCursor().X = 0
2519 for i, r := range line {
2520 if r != ' ' && r != '\t' {
2521 b.PrimaryCursor().X = i
2522 break
2523 }
2524 }
2525}
2526
2527// saveState captures a deep copy of the current buffer and cursors for the undo stack.
2528func (e *Editor) saveState() {
2529 b := e.activeBuffer()
2530 if b == nil {
2531 return
2532 }
2533 // Deep copy the buffer to ensure historical states aren't mutated.
2534 bufferCopy := make([][]rune, len(b.buffer))
2535 for i, line := range b.buffer {
2536 lineCopy := make([]rune, len(line))
2537 copy(lineCopy, line)
2538 bufferCopy[i] = lineCopy
2539 }
2540
2541 // Deep copy cursors.
2542 cursorsCopy := make([]Cursor, len(b.cursors))
2543 copy(cursorsCopy, b.cursors)
2544
2545 b.undoStack = append(b.undoStack, HistoryState{
2546 buffer: bufferCopy,
2547 cursors: cursorsCopy,
2548 })
2549 // Cap undo stack at 100 entries to prevent memory exhaustion.
2550 if len(b.undoStack) > 100 {
2551 b.undoStack = b.undoStack[1:]
2552 }
2553 // Clear the redo stack whenever a new action is performed.
2554 b.redoStack = []HistoryState{}
2555}
2556
2557func (e *Editor) undo() {
2558 b := e.activeBuffer()
2559 if b == nil || len(b.undoStack) == 0 {
2560 return
2561 }
2562
2563 // Save current state to redo stack
2564 bufferCopy := make([][]rune, len(b.buffer))
2565 for i, line := range b.buffer {
2566 lineCopy := make([]rune, len(line))
2567 copy(lineCopy, line)
2568 bufferCopy[i] = lineCopy
2569 }
2570 cursorsCopy := make([]Cursor, len(b.cursors))
2571 copy(cursorsCopy, b.cursors)
2572
2573 b.redoStack = append(b.redoStack, HistoryState{
2574 buffer: bufferCopy,
2575 cursors: cursorsCopy,
2576 })
2577
2578 // Restore from undo stack
2579 state := b.undoStack[len(b.undoStack)-1]
2580 b.undoStack = b.undoStack[:len(b.undoStack)-1]
2581 b.buffer = state.buffer
2582 b.cursors = state.cursors
2583
2584 if b.syntax != nil {
2585 b.syntax.Parse([]byte(b.toString()))
2586 }
2587}
2588
2589func (e *Editor) redo() {
2590 b := e.activeBuffer()
2591 if b == nil || len(b.redoStack) == 0 {
2592 return
2593 }
2594
2595 // Save current state to undo stack
2596 bufferCopy := make([][]rune, len(b.buffer))
2597 for i, line := range b.buffer {
2598 lineCopy := make([]rune, len(line))
2599 copy(lineCopy, line)
2600 bufferCopy[i] = lineCopy
2601 }
2602 cursorsCopy := make([]Cursor, len(b.cursors))
2603 copy(cursorsCopy, b.cursors)
2604
2605 b.undoStack = append(b.undoStack, HistoryState{
2606 buffer: bufferCopy,
2607 cursors: cursorsCopy,
2608 })
2609
2610 // Restore from redo stack
2611 state := b.redoStack[len(b.redoStack)-1]
2612 b.redoStack = b.redoStack[:len(b.redoStack)-1]
2613 b.buffer = state.buffer
2614 b.cursors = state.cursors
2615
2616 if b.syntax != nil {
2617 b.syntax.Parse([]byte(b.toString()))
2618 }
2619}
2620
2621// JoinLines joins the current line with the next one.
2622func (e *Editor) JoinLines() {
2623 b := e.activeBuffer()
2624 if b == nil || len(b.buffer) <= 1 {
2625 return
2626 }
2627 if b.readOnly {
2628 e.message = "File is read-only"
2629 return
2630 }
2631
2632 cursor := b.PrimaryCursor()
2633 if cursor.Y >= len(b.buffer)-1 {
2634 return // Last line, nothing to join
2635 }
2636
2637 currentLine := b.buffer[cursor.Y]
2638 nextLine := b.buffer[cursor.Y+1]
2639
2640 // Trim leading whitespace from next line
2641 trimIdx := 0
2642 for trimIdx < len(nextLine) && (nextLine[trimIdx] == ' ' || nextLine[trimIdx] == '\t') {
2643 trimIdx++
2644 }
2645 trimmedNextLine := nextLine[trimIdx:]
2646
2647 // Determine if we need a space between lines
2648 needsSpace := true
2649 if len(currentLine) == 0 || (len(currentLine) > 0 && currentLine[len(currentLine)-1] == ' ') {
2650 needsSpace = false
2651 }
2652 if len(trimmedNextLine) == 0 {
2653 needsSpace = false
2654 }
2655
2656 // Join lines
2657 newLine := make([]rune, 0, len(currentLine)+len(trimmedNextLine)+1)
2658 newLine = append(newLine, currentLine...)
2659 if needsSpace {
2660 newLine = append(newLine, ' ')
2661 }
2662 newLine = append(newLine, trimmedNextLine...)
2663
2664 // Update buffer
2665 b.buffer[cursor.Y] = newLine
2666 b.buffer = append(b.buffer[:cursor.Y+1], b.buffer[cursor.Y+2:]...)
2667
2668 // Set cursor position to the join point
2669 cursor.X = len(currentLine)
2670 if needsSpace {
2671 // Vim usually puts cursor on the space
2672 } else if cursor.X >= len(newLine) && len(newLine) > 0 {
2673 cursor.X = len(newLine) - 1
2674 }
2675
2676 // Syntax update
2677 if b.syntax != nil {
2678 b.syntax.Reparse([]byte(b.toString()))
2679 }
2680 e.markModified()
2681}
2682
2683// getSelectionBounds returns the normalized coordinates (top-left to bottom-right) of the visual selection.
2684func (e *Editor) getSelectionBounds() (int, int, int, int) {
2685 b := e.activeBuffer()
2686 y1, x1, y2, x2 := e.visualStartY, e.visualStartX, b.PrimaryCursor().Y, b.PrimaryCursor().X
2687
2688 // Normalize so (y1, x1) is always the "earlier" point in the file.
2689 if y1 > y2 || (y1 == y2 && x1 > x2) {
2690 y1, x1, y2, x2 = y2, x2, y1, x1
2691 }
2692
2693 // Force line-wise bounds if in Visual Line mode.
2694 if e.mode == ModeVisualLine {
2695 x1 = 0
2696 if y2 < len(b.buffer) {
2697 x2 = len(b.buffer[y2])
2698 if x2 > 0 {
2699 x2-- // last character index
2700 } else {
2701 x2 = 0
2702 }
2703 }
2704 }
2705
2706 return y1, x1, y2, x2
2707}
2708
2709// ollamaComplete sends the selection to the Ollama AI and replaces it with the generated text.
2710func (e *Editor) ollamaComplete() {
2711 b := e.activeBuffer()
2712 if b == nil {
2713 return
2714 }
2715 if e.ollamaClient == nil || !e.ollamaClient.IsOnline {
2716 e.message = "Ollama is offline"
2717 return
2718 }
2719
2720 y1, x1, y2, x2 := e.getSelectionBounds()
2721
2722 // Extract selected text for the prompt.
2723 var selectedText strings.Builder
2724 for y := y1; y <= y2; y++ {
2725 line := b.buffer[y]
2726 if y == y1 && y == y2 {
2727 if x1 < len(line) {
2728 end := x2 + 1
2729 if end > len(line) {
2730 end = len(line)
2731 }
2732 selectedText.WriteString(string(line[x1:end]))
2733 }
2734 } else if y == y1 {
2735 if x1 < len(line) {
2736 selectedText.WriteString(string(line[x1:]))
2737 }
2738 selectedText.WriteRune('\n')
2739 } else if y == y2 {
2740 end := x2 + 1
2741 if end > len(line) {
2742 end = len(line)
2743 }
2744 selectedText.WriteString(string(line[:end]))
2745 } else {
2746 selectedText.WriteString(string(line))
2747 selectedText.WriteRune('\n')
2748 }
2749 }
2750
2751 prompt := selectedText.String()
2752 if prompt == "" {
2753 return
2754 }
2755
2756 // Read system prompt (template) from the embedded assets.
2757 instr, err := ContentFS.ReadFile("content/ollama.txt")
2758 if err == nil {
2759 prompt += "\n" + string(instr)
2760 }
2761
2762 firstLine := strings.Split(prompt, "\n")[0]
2763 if len(firstLine) > 50 {
2764 firstLine = firstLine[:47] + "..."
2765 }
2766 e.message = fmt.Sprintf("Ollama is thinking about: %s", firstLine)
2767 e.draw()
2768
2769 // Call the Ollama API.
2770 response, err := e.ollamaClient.Generate(prompt)
2771 if err != nil {
2772 e.message = fmt.Sprintf("Ollama error: %v", err)
2773 return
2774 }
2775
2776 // Replace the visual selection with the AI's response.
2777 e.saveState()
2778 e.deleteVisualSelection()
2779
2780 lines := strings.Split(strings.TrimSpace(response), "\n")
2781
2782 at := b.PrimaryCursor().X
2783 currentLine := b.buffer[b.PrimaryCursor().Y]
2784 hasSuffix := at < len(currentLine)
2785
2786 nextExists := b.PrimaryCursor().Y+1 < len(b.buffer)
2787 nextIsBlank := false
2788 if nextExists {
2789 nextIsBlank = len(b.buffer[b.PrimaryCursor().Y+1]) == 0
2790 }
2791
2792 // Add formatting newlines if necessary.
2793 if hasSuffix {
2794 lines = append(lines, "", "")
2795 } else if !nextIsBlank {
2796 lines = append(lines, "")
2797 }
2798
2799 if len(lines) == 1 {
2800 line := b.buffer[b.PrimaryCursor().Y]
2801 at := b.PrimaryCursor().X
2802 if at > len(line) {
2803 at = len(line)
2804 }
2805
2806 respRunes := []rune(lines[0])
2807 newLine := make([]rune, len(line)+len(respRunes))
2808 copy(newLine[:at], line[:at])
2809 copy(newLine[at:], respRunes)
2810 copy(newLine[at+len(respRunes):], line[at:])
2811 b.buffer[b.PrimaryCursor().Y] = newLine
2812 b.PrimaryCursor().X = at + len(respRunes)
2813 } else {
2814 line := b.buffer[b.PrimaryCursor().Y]
2815 at := b.PrimaryCursor().X
2816 if at > len(line) {
2817 at = len(line)
2818 }
2819
2820 prefix := line[:at]
2821 suffix := line[at:]
2822
2823 newLines := make([][]rune, len(lines))
2824 for i, l := range lines {
2825 newLines[i] = []rune(l)
2826 }
2827
2828 newLines[0] = append([]rune(string(prefix)), newLines[0]...)
2829 newLines[len(newLines)-1] = append(newLines[len(newLines)-1], suffix...)
2830
2831 newBuffer := make([][]rune, len(b.buffer)+len(newLines)-1)
2832 copy(newBuffer[:b.PrimaryCursor().Y], b.buffer[:b.PrimaryCursor().Y])
2833 copy(newBuffer[b.PrimaryCursor().Y:], newLines)
2834 copy(newBuffer[b.PrimaryCursor().Y+len(newLines):], b.buffer[b.PrimaryCursor().Y+1:])
2835 b.buffer = newBuffer
2836
2837 b.PrimaryCursor().Y = b.PrimaryCursor().Y + len(newLines) - 1
2838 b.PrimaryCursor().X = len(newLines[len(newLines)-1]) - len(suffix)
2839 }
2840
2841 e.mode = ModeNormal
2842 e.markModified()
2843 e.message = "Ollama completion inserted (replaced selection)"
2844
2845 if b.syntax != nil {
2846 b.syntax.Parse([]byte(b.toString()))
2847 }
2848}
2849
2850func (e *Editor) getSelection() []rune {
2851 b := e.activeBuffer()
2852 y1, x1, y2, x2 := e.getSelectionBounds()
2853 var selection []rune
2854
2855 for y := y1; y <= y2; y++ {
2856 line := b.buffer[y]
2857 start := 0
2858 end := len(line)
2859
2860 if e.mode == ModeVisualBlock {
2861 startX := x1
2862 endX := x2
2863 if startX > endX {
2864 startX, endX = endX, startX
2865 }
2866 start = startX
2867 end = endX + 1 // inclusive
2868 } else if e.mode != ModeVisualLine {
2869 if y == y1 {
2870 start = x1
2871 }
2872 if y == y2 {
2873 end = x2 + 1 // inclusive
2874 }
2875 }
2876
2877 if start > len(line) {
2878 start = len(line)
2879 }
2880 if end > len(line) {
2881 end = len(line)
2882 }
2883
2884 if start < end {
2885 selection = append(selection, line[start:end]...)
2886 }
2887
2888 if (y < y2 || e.mode == ModeVisualLine) && e.mode != ModeVisualBlock {
2889 selection = append(selection, '\n')
2890 } else if e.mode == ModeVisualBlock && y < y2 {
2891 selection = append(selection, '\n')
2892 }
2893 }
2894 return selection
2895}
2896
2897func (e *Editor) deleteVisualSelection() {
2898 b := e.activeBuffer()
2899 y1, x1, y2, x2 := e.getSelectionBounds()
2900 if b.readOnly {
2901 e.message = "File is read-only"
2902 return
2903 }
2904
2905 // Copy to clipboard
2906 e.clipboard = e.getSelection()
2907
2908 if e.mode == ModeVisualLine {
2909 // Remove all selected lines
2910 b.buffer = append(b.buffer[:y1], b.buffer[y2+1:]...)
2911 if len(b.buffer) == 0 {
2912 b.buffer = [][]rune{{}}
2913 }
2914 if y1 >= len(b.buffer) {
2915 y1 = len(b.buffer) - 1
2916 }
2917 b.PrimaryCursor().Y = y1
2918 b.PrimaryCursor().X = 0
2919 } else if e.mode == ModeVisualBlock {
2920 startX := x1
2921 endX := x2
2922 if startX > endX {
2923 startX, endX = endX, startX
2924 }
2925
2926 for y := y1; y <= y2; y++ {
2927 if y < len(b.buffer) {
2928 line := b.buffer[y]
2929 s := startX
2930 e := endX + 1
2931 if s > len(line) {
2932 s = len(line)
2933 }
2934 if e > len(line) {
2935 e = len(line)
2936 }
2937
2938 if s < e {
2939 newLine := append(line[:s], line[e:]...)
2940 b.buffer[y] = newLine
2941 }
2942 }
2943 }
2944 b.PrimaryCursor().Y = y1
2945 b.PrimaryCursor().X = startX
2946 } else {
2947 // Modify buffer for character-wise selection
2948 line1 := b.buffer[y1]
2949 line2 := b.buffer[y2]
2950
2951 prefix := make([]rune, x1)
2952 copy(prefix, line1[:x1])
2953
2954 suffix := []rune{}
2955 if x2+1 < len(line2) {
2956 suffix = make([]rune, len(line2)-(x2+1))
2957 copy(suffix, line2[x2+1:])
2958 }
2959
2960 newLine := append(prefix, suffix...)
2961 b.buffer[y1] = newLine
2962
2963 // Remove lines between
2964 if y1 != y2 {
2965 b.buffer = append(b.buffer[:y1+1], b.buffer[y2+1:]...)
2966 }
2967
2968 b.PrimaryCursor().Y = y1
2969 b.PrimaryCursor().X = x1
2970 }
2971
2972 e.mode = ModeNormal
2973 e.markModified()
2974
2975 if b.syntax != nil {
2976 b.syntax.Parse([]byte(b.toString()))
2977 }
2978}
2979
2980func (e *Editor) yankVisualSelection() {
2981 e.clipboard = e.getSelection()
2982 e.mode = ModeNormal
2983}
2984
2985func (e *Editor) changeVisualSelection() {
2986 b := e.activeBuffer()
2987 if b != nil && b.readOnly {
2988 e.message = "File is read-only"
2989 return
2990 }
2991 e.deleteVisualSelection()
2992 e.mode = ModeInsert
2993}
2994
2995func (e *Editor) pasteVisualSelection() {
2996 if len(e.clipboard) == 0 {
2997 return
2998 }
2999 b := e.activeBuffer()
3000 if b != nil && b.readOnly {
3001 e.message = "File is read-only"
3002 return
3003 }
3004 // Save clipboard because deleteVisualSelection overwrites it
3005 tmpClipboard := make([]rune, len(e.clipboard))
3006 copy(tmpClipboard, e.clipboard)
3007
3008 e.deleteVisualSelection()
3009
3010 // Restore clipboard and paste
3011 e.clipboard = tmpClipboard
3012 e.pasteLineAbove()
3013}
3014
3015func (e *Editor) toggleComment(y int) {
3016 b := e.activeBuffer()
3017 if b == nil || len(b.buffer) == 0 || b.fileType == nil || b.fileType.Comment == "" {
3018 return
3019 }
3020 if b.readOnly {
3021 e.message = "File is read-only"
3022 return
3023 }
3024 if y < 0 || y >= len(b.buffer) {
3025 return
3026 }
3027
3028 line := b.buffer[y]
3029 if len(line) == 0 {
3030 return
3031 }
3032
3033 comment := []rune(b.fileType.Comment)
3034
3035 // Check if already commented at the beginning of the line
3036 isCommented := false
3037 if len(line) >= len(comment) {
3038 match := true
3039 for i, r := range comment {
3040 if line[i] != r {
3041 match = false
3042 break
3043 }
3044 }
3045 isCommented = match
3046 }
3047
3048 var newLine []rune
3049 if isCommented {
3050 // Uncomment
3051 contentStart := len(comment)
3052 // Skip optional following space
3053 if contentStart < len(line) && line[contentStart] == ' ' {
3054 contentStart++
3055 }
3056 newLine = append(newLine, line[contentStart:]...)
3057 } else {
3058 // Comment
3059 newLine = append(newLine, comment...)
3060 newLine = append(newLine, ' ')
3061 newLine = append(newLine, line...)
3062 }
3063
3064 b.buffer[y] = newLine
3065 e.markModified()
3066
3067 if b.syntax != nil {
3068 b.syntax.Parse([]byte(b.toString()))
3069 }
3070}
3071
3072func (e *Editor) toggleCommentLine() {
3073 b := e.activeBuffer()
3074 if b != nil {
3075 e.toggleComment(b.PrimaryCursor().Y)
3076 }
3077}
3078
3079func (e *Editor) commentVisualSelection() {
3080 y1, _, y2, _ := e.getSelectionBounds()
3081 for y := y1; y <= y2; y++ {
3082 e.toggleComment(y)
3083 }
3084 e.mode = ModeNormal
3085}
3086
3087func (e *Editor) toggleCase(y, x int) (int, int) {
3088 b := e.activeBuffer()
3089 if b == nil || y < 0 || y >= len(b.buffer) {
3090 return y, x
3091 }
3092 line := b.buffer[y]
3093 if x < 0 || x >= len(line) {
3094 return y, x
3095 }
3096
3097 r := line[x]
3098 if unicode.IsLower(r) {
3099 line[x] = unicode.ToUpper(r)
3100 } else if unicode.IsUpper(r) {
3101 line[x] = unicode.ToLower(r)
3102 }
3103
3104 // Move cursor right
3105 newX := x + 1
3106 if newX >= len(line) {
3107 newX = len(line) - 1
3108 if newX < 0 {
3109 newX = 0
3110 }
3111 }
3112
3113 return y, newX
3114}
3115
3116func (e *Editor) ToggleCaseUnderCursor() {
3117 b := e.activeBuffer()
3118 if b == nil || len(b.buffer) == 0 {
3119 return
3120 }
3121 if b.readOnly {
3122 e.message = "File is read-only"
3123 return
3124 }
3125
3126 e.saveState()
3127 b.PrimaryCursor().Y, b.PrimaryCursor().X = e.toggleCase(b.PrimaryCursor().Y, b.PrimaryCursor().X)
3128 e.markModified()
3129
3130 if b.syntax != nil {
3131 b.syntax.Parse([]byte(b.toString()))
3132 }
3133}
3134
3135func (e *Editor) ToggleCaseVisualSelection() {
3136 b := e.activeBuffer()
3137 if b == nil || len(b.buffer) == 0 {
3138 return
3139 }
3140 if b.readOnly {
3141 e.message = "File is read-only"
3142 return
3143 }
3144
3145 e.saveState()
3146 y1, x1, y2, x2 := e.getSelectionBounds()
3147
3148 for y := y1; y <= y2; y++ {
3149 line := b.buffer[y]
3150 start := 0
3151 end := len(line) - 1
3152 if y == y1 {
3153 start = x1
3154 }
3155 if y == y2 {
3156 end = x2
3157 }
3158
3159 for x := start; x <= end && x < len(line); x++ {
3160 r := line[x]
3161 if unicode.IsLower(r) {
3162 line[x] = unicode.ToUpper(r)
3163 } else if unicode.IsUpper(r) {
3164 line[x] = unicode.ToLower(r)
3165 }
3166 }
3167 }
3168
3169 e.mode = ModeNormal
3170 e.markModified()
3171
3172 if b.syntax != nil {
3173 b.syntax.Parse([]byte(b.toString()))
3174 }
3175}
3176
3177// detectCommentPrefix checks if the given text starts with a known comment marker.
3178// Returns the comment prefix including trailing space if present, or empty string if not a comment.
3179// Uses Config.FormatterMarkers which can be customized in config.go.
3180func detectCommentPrefix(text string) string {
3181 for _, marker := range Config.FormatterMarkers {
3182 // Check for marker with space first (e.g., "// ")
3183 if strings.HasPrefix(text, marker+" ") {
3184 return marker + " "
3185 }
3186 // Then check for marker without space (e.g., "//")
3187 if strings.HasPrefix(text, marker) {
3188 return marker
3189 }
3190 }
3191 return ""
3192}
3193
3194// formatText wraps text to 80 characters (gq-style formatting).
3195// It formats either the current line in normal mode or the selected lines in visual modes.
3196func (e *Editor) formatText() {
3197 b := e.activeBuffer()
3198 if b == nil || len(b.buffer) == 0 {
3199 return
3200 }
3201 if b.readOnly {
3202 e.message = "File is read-only"
3203 return
3204 }
3205
3206 var startLine, endLine int
3207
3208 // Determine which lines to format based on current mode
3209 if e.mode == ModeNormal {
3210 // In normal mode, format only the current line
3211 startLine = b.PrimaryCursor().Y
3212 endLine = b.PrimaryCursor().Y
3213 } else if e.mode == ModeVisual || e.mode == ModeVisualLine {
3214 // In visual modes, format the selected lines
3215 startLine = e.visualStartY
3216 endLine = b.PrimaryCursor().Y
3217 if startLine > endLine {
3218 startLine, endLine = endLine, startLine
3219 }
3220 } else {
3221 return
3222 }
3223
3224 e.saveState()
3225
3226 const maxWidth = 80
3227 var newLines [][]rune
3228
3229 // Process lines in groups (paragraphs) with the same indentation and comment prefix
3230 lineIdx := startLine
3231 for lineIdx <= endLine && lineIdx < len(b.buffer) {
3232 line := b.buffer[lineIdx]
3233
3234 // Handle empty lines
3235 if len(line) == 0 {
3236 newLines = append(newLines, []rune{})
3237 lineIdx++
3238 continue
3239 }
3240
3241 // Get leading whitespace (indentation) for this line
3242 indent := 0
3243 for indent < len(line) && unicode.IsSpace(line[indent]) {
3244 indent++
3245 }
3246 indentStr := line[:indent]
3247
3248 // Get the text content (without indentation)
3249 content := line[indent:]
3250 contentStr := string(content)
3251
3252 // Detect comment markers after indentation
3253 commentPrefix := detectCommentPrefix(contentStr)
3254 commentPrefixRunes := []rune(commentPrefix)
3255
3256 // Collect all consecutive lines with the same indentation and comment prefix
3257 var paragraphText []string
3258 paragraphStartIdx := lineIdx
3259
3260 for lineIdx <= endLine && lineIdx < len(b.buffer) {
3261 currentLine := b.buffer[lineIdx]
3262
3263 // Stop at empty lines
3264 if len(currentLine) == 0 {
3265 break
3266 }
3267
3268 // Check if this line has the same indentation
3269 currentIndent := 0
3270 for currentIndent < len(currentLine) && unicode.IsSpace(currentLine[currentIndent]) {
3271 currentIndent++
3272 }
3273
3274 if currentIndent != indent {
3275 break
3276 }
3277
3278 // Check if this line has the same comment prefix
3279 currentContent := currentLine[currentIndent:]
3280 currentContentStr := string(currentContent)
3281 currentCommentPrefix := detectCommentPrefix(currentContentStr)
3282
3283 if currentCommentPrefix != commentPrefix {
3284 break
3285 }
3286
3287 // Extract the actual text (after comment prefix)
3288 textContent := currentContentStr
3289 if len(commentPrefix) > 0 {
3290 textContent = currentContentStr[len(commentPrefix):]
3291 }
3292
3293 paragraphText = append(paragraphText, strings.TrimSpace(textContent))
3294 lineIdx++
3295 }
3296
3297 // Join all the text from the paragraph
3298 fullText := strings.Join(paragraphText, " ")
3299
3300 // Split into words
3301 words := strings.Fields(fullText)
3302 if len(words) == 0 {
3303 // Just preserve the line structure if no words
3304 for i := paragraphStartIdx; i < lineIdx; i++ {
3305 newLines = append(newLines, b.buffer[i])
3306 }
3307 continue
3308 }
3309
3310 // Now wrap the combined text
3311 wrapWidth := maxWidth - indent - len(commentPrefixRunes)
3312 if wrapWidth < 20 {
3313 wrapWidth = 20 // Minimum wrap width to avoid infinite loops
3314 }
3315
3316 var wrappedLines [][]rune
3317 currentLine := make([]rune, 0)
3318 currentLine = append(currentLine, indentStr...)
3319 currentLine = append(currentLine, commentPrefixRunes...)
3320
3321 for i, word := range words {
3322 wordRunes := []rune(word)
3323
3324 // Calculate the length if we add this word
3325 // Account for space before word (except for first word on a line)
3326 spaceNeeded := 0
3327 if len(currentLine) > indent+len(commentPrefixRunes) {
3328 spaceNeeded = 1 // Need a space before the word
3329 }
3330
3331 projectedLen := len(currentLine) + spaceNeeded + len(wordRunes)
3332
3333 if projectedLen > maxWidth && len(currentLine) > indent+len(commentPrefixRunes) {
3334 // Adding this word would exceed max width, so start a new line
3335 wrappedLines = append(wrappedLines, currentLine)
3336 currentLine = make([]rune, 0)
3337 currentLine = append(currentLine, indentStr...)
3338 currentLine = append(currentLine, commentPrefixRunes...)
3339 currentLine = append(currentLine, wordRunes...)
3340 } else {
3341 // Add the word to the current line
3342 if len(currentLine) > indent+len(commentPrefixRunes) {
3343 currentLine = append(currentLine, ' ')
3344 }
3345 currentLine = append(currentLine, wordRunes...)
3346 }
3347
3348 // If this is the last word, add the current line
3349 if i == len(words)-1 {
3350 wrappedLines = append(wrappedLines, currentLine)
3351 }
3352 }
3353
3354 newLines = append(newLines, wrappedLines...)
3355 }
3356
3357 // Replace the lines in the buffer
3358 if len(newLines) > 0 {
3359 newBuffer := make([][]rune, 0)
3360 newBuffer = append(newBuffer, b.buffer[:startLine]...)
3361 newBuffer = append(newBuffer, newLines...)
3362 if endLine+1 < len(b.buffer) {
3363 newBuffer = append(newBuffer, b.buffer[endLine+1:]...)
3364 }
3365 b.buffer = newBuffer
3366
3367 // Adjust cursor position
3368 if b.PrimaryCursor().Y > len(b.buffer)-1 {
3369 b.PrimaryCursor().Y = len(b.buffer) - 1
3370 }
3371 if b.PrimaryCursor().Y >= 0 && b.PrimaryCursor().X > len(b.buffer[b.PrimaryCursor().Y]) {
3372 b.PrimaryCursor().X = len(b.buffer[b.PrimaryCursor().Y])
3373 }
3374 }
3375
3376 e.mode = ModeNormal
3377 e.markModified()
3378
3379 if b.syntax != nil {
3380 b.syntax.Parse([]byte(b.toString()))
3381 }
3382
3383 e.message = "Text formatted"
3384}
3385
3386// performSearch performs a linear case-insensitive search for a query string.
3387func (e *Editor) performSearch(query string, forward bool) {
3388 b := e.activeBuffer()
3389 if b == nil || len(b.buffer) == 0 || query == "" {
3390 return
3391 }
3392
3393 queryLower := strings.ToLower(query)
3394 startY := b.PrimaryCursor().Y
3395 startX := b.PrimaryCursor().X
3396
3397 dir := 1
3398 if !forward {
3399 dir = -1
3400 }
3401
3402 y := startY
3403 firstLoop := true
3404
3405 // Loop through the entire buffer once.
3406 for i := 0; i <= len(b.buffer); i++ {
3407 line := string(b.buffer[y])
3408 lineLower := strings.ToLower(line)
3409
3410 matches := []int{}
3411 // Scan line for all occurrences.
3412 for pos := 0; pos < len(lineLower); {
3413 idx := strings.Index(lineLower[pos:], queryLower)
3414 if idx == -1 {
3415 break
3416 }
3417 matchPos := pos + idx
3418 matches = append(matches, matchPos)
3419 pos = matchPos + 1
3420 }
3421
3422 if len(matches) > 0 {
3423 if forward {
3424 for _, m := range matches {
3425 // Ensure we skip the current cursor position on the first line.
3426 if firstLoop && m <= startX {
3427 continue
3428 }
3429 b.PrimaryCursor().Y = y
3430 b.PrimaryCursor().X = m
3431 return
3432 }
3433 } else {
3434 for j := len(matches) - 1; j >= 0; j-- {
3435 m := matches[j]
3436 if firstLoop && m >= startX {
3437 continue
3438 }
3439 b.PrimaryCursor().Y = y
3440 b.PrimaryCursor().X = m
3441 return
3442 }
3443 }
3444 }
3445
3446 // Wrap around buffer boundaries.
3447 y += dir
3448 if y < 0 {
3449 y = len(b.buffer) - 1
3450 } else if y >= len(b.buffer) {
3451 y = 0
3452 }
3453
3454 firstLoop = false
3455 }
3456}
3457
3458func (e *Editor) findNext() {
3459 e.pushJump()
3460 e.performSearch(e.lastSearch, true)
3461}
3462
3463func (e *Editor) findPrev() {
3464 e.pushJump()
3465 e.performSearch(e.lastSearch, false)
3466}
3467
3468func (e *Editor) checkDiagnostics() {
3469 b := e.activeBuffer()
3470 if b == nil || b.lspClient == nil {
3471 return
3472 }
3473
3474 e.addLog("LSP", "Checking diagnostics...")
3475
3476 // Send current buffer content to LSP
3477 content := e.bufferToString(b.buffer)
3478 if err := b.lspClient.SendDidChange(content); err != nil {
3479 e.addLog("LSP", fmt.Sprintf("didChange error: %v", err))
3480 return
3481 }
3482
3483 // Diagnostics will be updated asynchronously when clangd sends publishDiagnostics
3484 // The background readMessages goroutine handles this automatically
3485 // Get current diagnostics (may be from previous check)
3486 b.diagnostics = b.lspClient.GetDiagnostics()
3487 e.addLog("LSP", fmt.Sprintf("Current diagnostics: %d", len(b.diagnostics)))
3488}
3489
3490func (e *Editor) deleteCurrentBuffer() {
3491 if len(e.buffers) == 0 {
3492 return
3493 }
3494
3495 // Shutdown LSP client if active
3496 b := e.activeBuffer()
3497 if b != nil && b.lspClient != nil {
3498 b.lspClient.Shutdown()
3499 }
3500
3501 // Remove the current buffer
3502 e.buffers = append(e.buffers[:e.activeBufferIndex], e.buffers[e.activeBufferIndex+1:]...)
3503
3504 // Adjust active buffer index
3505 if len(e.buffers) == 0 {
3506 // No more buffers, create an empty one
3507 defaultType := fileTypes[len(fileTypes)-1]
3508 e.buffers = append(e.buffers, &Buffer{
3509 buffer: [][]rune{{}},
3510 undoStack: []HistoryState{},
3511 redoStack: []HistoryState{},
3512 fileType: defaultType,
3513 })
3514 e.activeBufferIndex = 0
3515 } else if e.activeBufferIndex >= len(e.buffers) {
3516 e.activeBufferIndex = len(e.buffers) - 1
3517 }
3518}
3519
3520// drawStatusBar renders the bottom-aligned information bar showing file details and editor state.
3521func (e *Editor) drawStatusBar(statusY int) {
3522 w, _ := termbox.Size()
3523 b := e.activeBuffer()
3524 if b == nil {
3525 return
3526 }
3527
3528 modeStr := "UNKNOWN"
3529
3530 // Fill background for the entire status line.
3531 for x := 0; x < w; x++ {
3532 fg, bg := GetThemeColor(ColorStatusBar)
3533 termbox.SetCell(x, statusY, ' ', fg, bg)
3534 }
3535
3536 // Draw the primary mode indicator.
3537 var fg, bg termbox.Attribute
3538 switch e.mode {
3539 case ModeInsert:
3540 modeStr = "INSERT"
3541 fg, bg = GetThemeColor(ColorInsertMode)
3542 case ModeVisual, ModeVisualLine, ModeVisualBlock:
3543 modeStr = "VISUAL"
3544 fg, bg = GetThemeColor(ColorVisualMode)
3545 case ModeFuzzy:
3546 switch e.fuzzyType {
3547 case FuzzyModeFile:
3548 modeStr = "FILES"
3549 fg, bg = GetThemeColor(ColorFuzzyModeFiles)
3550 case FuzzyModeBuffer:
3551 modeStr = "BUFFERS"
3552 fg, bg = GetThemeColor(ColorFuzzyModeBuffers)
3553 case FuzzyModeWarning:
3554 modeStr = "WARNINGS"
3555 fg, bg = GetThemeColor(ColorFuzzyModeWarnings)
3556 default:
3557 modeStr = "FUZZY"
3558 fg, bg = GetThemeColor(ColorNormalMode)
3559 }
3560 default:
3561 modeStr = "NORMAL"
3562 fg, bg = GetThemeColor(ColorNormalMode)
3563 }
3564
3565 termbox.SetCell(0, statusY, ' ', fg, bg)
3566 for i, r := range modeStr {
3567 termbox.SetCell(i+1, statusY, r, fg, bg)
3568 }
3569 termbox.SetCell(len(modeStr)+1, statusY, ' ', fg, bg)
3570
3571 // Draw filename and modification status.
3572 fileStr := "[no file]"
3573 if b.filename != "" {
3574 fileStr = b.filename
3575 }
3576 if b.modified {
3577 fileStr += " [+]"
3578 }
3579 if b.readOnly {
3580 fileStr += " (read-only)"
3581 }
3582 fileX := len(modeStr) + 2 + 1
3583 for i, r := range fileStr {
3584 fg, bg := GetThemeColor(ColorStatusBar)
3585 termbox.SetCell(fileX+i, statusY, r, fg, bg)
3586 }
3587
3588 // Draw cursor coordinates and file metadata.
3589 lineNum := b.PrimaryCursor().Y + 1
3590 visualCol := e.bufferToVisual(b.buffer[b.PrimaryCursor().Y], b.PrimaryCursor().X) + 1
3591 totalLines := len(b.buffer)
3592 percent := 0
3593 if totalLines > 0 {
3594 percent = (lineNum * 100) / totalLines
3595 }
3596 fileTypeStr := "text"
3597 if b.fileType != nil {
3598 fileTypeStr = strings.ToLower(b.fileType.Name)
3599 }
3600 statusRight := fmt.Sprintf("(%s) [%d/%d] %d,%d %d%% ", fileTypeStr, e.activeBufferIndex+1, len(e.buffers), lineNum, visualCol, percent)
3601 rightPositionWidth := 6
3602 rightX := w - len(statusRight) - rightPositionWidth
3603 for i, r := range statusRight {
3604 fg, bg := GetThemeColor(ColorStatusBar)
3605 termbox.SetCell(rightX+i, statusY, r, fg, bg)
3606 }
3607
3608 // Draw connectivity status for LSP and Ollama.
3609 lspColor := ColorLSPStatusDisconnected
3610 if b.lspClient != nil {
3611 lspColor = ColorLSPStatusConnected
3612 }
3613 fgL, bgL := GetThemeColor(lspColor)
3614 for i, r := range " L " {
3615 termbox.SetCell(w-6+i, statusY, r, fgL, bgL)
3616 }
3617
3618 ollamaColor := ColorOllamaStatusDisconnected
3619 if e.ollamaClient != nil && e.ollamaClient.IsOnline {
3620 ollamaColor = ColorOllamaStatusConnected
3621 }
3622 fgO, bgO := GetThemeColor(ollamaColor)
3623 for i, r := range " O " {
3624 termbox.SetCell(w-3+i, statusY, r, fgO, bgO)
3625 }
3626}
3627
3628func (e *Editor) drawCommandBar(cmdY int) {
3629 w, _ := termbox.Size()
3630 for x := 0; x < w; x++ {
3631 fg, bg := GetThemeColor(ColorDefault)
3632 termbox.SetCell(x, cmdY, ' ', fg, bg)
3633 }
3634
3635 prompt := ""
3636 buffer := []rune{}
3637 startX := 0
3638 if e.mode == ModeCommand {
3639 prompt = ":"
3640 buffer = e.commandBuffer
3641 } else if e.mode == ModeFuzzy {
3642 prompt = "> "
3643 buffer = e.fuzzyBuffer
3644 startX = 1
3645 } else if e.mode == ModeFind {
3646 prompt = "/"
3647 buffer = e.findBuffer
3648 } else if e.mode == ModeReplace {
3649 prompt = "replace: "
3650 buffer = e.replaceInput
3651 } else if e.message != "" {
3652 // Draw transient message
3653 for i, r := range e.message {
3654 if i >= w {
3655 break
3656 }
3657 fg, bg := GetThemeColor(ColorDefault)
3658 termbox.SetCell(i, cmdY, r, fg, bg)
3659 }
3660 return
3661 } else {
3662 // Show LSP diagnostics when not in command/fuzzy/find mode
3663 b := e.activeBuffer()
3664 if b != nil && len(b.diagnostics) > 0 {
3665 // Count errors and warnings
3666 errorCount := 0
3667 for _, d := range b.diagnostics {
3668 if d.Severity == 1 { // Error
3669 errorCount++
3670 }
3671 }
3672
3673 // Show diagnostic summary
3674 diagStr := ""
3675 if errorCount > 0 {
3676 diagStr = fmt.Sprintf("%d error(s): ", errorCount)
3677 } else {
3678 diagStr = fmt.Sprintf("%d diag(s): ", len(b.diagnostics))
3679 }
3680
3681 // Add first error message (truncated if too long)
3682 // Make it as long as possible as width of the terminal allows
3683 if len(b.diagnostics) > 0 {
3684 firstMsg := b.diagnostics[0].Message
3685 maxMsgLen := w - len(diagStr)
3686 if len(firstMsg) > maxMsgLen {
3687 firstMsg = firstMsg[:maxMsgLen-3] + "..."
3688 }
3689 diagStr += firstMsg
3690 }
3691
3692 // Draw diagnostic text using theme colors
3693 fg, _ := GetThemeColor(ColorDiagSummaryError)
3694 if errorCount == 0 {
3695 fg, _ = GetThemeColor(ColorDiagSummaryWarning)
3696 }
3697
3698 for i, r := range diagStr {
3699 if i >= w {
3700 break
3701 }
3702 _, bg := GetThemeColor(ColorDefault)
3703 termbox.SetCell(i, cmdY, r, fg, bg)
3704 }
3705 return
3706 }
3707 }
3708
3709 // Draw prompt
3710 for i, r := range prompt {
3711 fg, bg := GetThemeColor(ColorDefault)
3712 termbox.SetCell(startX+i, cmdY, r, fg, bg)
3713 }
3714
3715 // Draw buffer content
3716 for i, r := range buffer {
3717 fg, bg := GetThemeColor(ColorDefault)
3718 termbox.SetCell(startX+len(prompt)+i, cmdY, r, fg, bg)
3719 }
3720}
3721
3722func (e *Editor) highlightLine(lineIdx int, line []rune) ([]termbox.Attribute, []termbox.Attribute) {
3723 fgAttrs := make([]termbox.Attribute, len(line))
3724 bgAttrs := make([]termbox.Attribute, len(line))
3725
3726 // specific default color for text
3727 defaultFg, defaultBg := GetThemeColor(ColorDefault)
3728
3729 for i := range fgAttrs {
3730 fgAttrs[i] = defaultFg
3731 bgAttrs[i] = defaultBg
3732 }
3733
3734 b := e.activeBuffer()
3735 if b != nil && b.syntax != nil {
3736 attrs := b.syntax.Highlight(lineIdx, line)
3737 // SyntaxHighlighter returns FG colors.
3738 // We trust it to return a slice of the same length as line (or we handle potential mismatch if necessary,
3739 // but the current implementation of Highlight seems to handle checks).
3740 // Overwrite default FGs with syntax FGs where they differ from default?
3741 // Or just replace entirely? syntax.Highlight initializes with defaultFg.
3742 // So we can just use it.
3743 fgAttrs = attrs
3744 }
3745
3746 return fgAttrs, bgAttrs
3747}
3748
3749func matchesKeyword(runes []rune, start int, keyword string) bool {
3750 if start+len(keyword) > len(runes) {
3751 return false
3752 }
3753 for i, ch := range keyword {
3754 if runes[start+i] != ch {
3755 return false
3756 }
3757 }
3758 return true
3759}
3760
3761func isWordStart(line []rune, i int) bool {
3762 if i == 0 {
3763 return true
3764 }
3765 r := line[i-1]
3766 // If previous char is not a word char, then this is start of word (assuming current is word char)
3767 return !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_')
3768}
3769
3770// draw is the main UI rendering loop.
3771func (e *Editor) draw() {
3772 _, defaultBg := GetThemeColor(ColorDefault)
3773 termbox.Clear(termbox.ColorDefault, defaultBg)
3774 w, h := termbox.Size()
3775 b := e.activeBuffer()
3776 if b == nil {
3777 termbox.Flush()
3778 return
3779 }
3780
3781 textWidth := w - Config.GutterWidth
3782 visibleHeight := h - 2
3783 if e.mode == ModeFuzzy {
3784 visibleHeight = h - 2 - Config.FuzzyFinderHeight
3785 }
3786
3787 // Vertical scroll management.
3788 if b.PrimaryCursor().Y < b.scrollY {
3789 b.scrollY = b.PrimaryCursor().Y
3790 }
3791 if b.PrimaryCursor().Y >= b.scrollY+visibleHeight {
3792 b.scrollY = b.PrimaryCursor().Y - visibleHeight + 1
3793 }
3794
3795 // Horizontal scroll management.
3796 visualCursorX := e.bufferToVisual(b.buffer[b.PrimaryCursor().Y], b.PrimaryCursor().X)
3797 if visualCursorX < b.scrollX {
3798 b.scrollX = visualCursorX
3799 }
3800 if visualCursorX >= b.scrollX+textWidth {
3801 b.scrollX = visualCursorX - textWidth + 1
3802 }
3803
3804 // Optimized mapping for faster cursor lookup during rendering.
3805 cursorMap := make(map[int]map[int]bool)
3806 for _, c := range b.cursors {
3807 if _, ok := cursorMap[c.Y]; !ok {
3808 cursorMap[c.Y] = make(map[int]bool)
3809 }
3810 cursorMap[c.Y][c.X] = true
3811 }
3812
3813 for screenY := 0; screenY < visibleHeight; screenY++ {
3814 bufferY := screenY + b.scrollY
3815 if bufferY < len(b.buffer) {
3816 // LSP diagnostic sign rendering.
3817 diagSign := ' '
3818 diagColor, diagBg := GetThemeColor(ColorDefault)
3819 if b.diagnostics != nil {
3820 for _, diag := range b.diagnostics {
3821 if diag.Range.Start.Line == bufferY {
3822 if diag.Severity == 1 {
3823 diagSign = 'E'
3824 diagColor, diagBg = GetThemeColor(ColorGutterSignError)
3825 } else if diag.Severity == 2 && diagSign != 'E' {
3826 diagSign = 'W'
3827 diagColor, diagBg = GetThemeColor(ColorGutterSignWarning)
3828 } else if diag.Severity == 3 && diagSign != 'E' {
3829 diagSign = 'I'
3830 diagColor, diagBg = GetThemeColor(ColorGutterSignInfo)
3831 } else if diag.Severity == 4 && diagSign != 'E' {
3832 diagSign = 'H'
3833 diagColor, diagBg = GetThemeColor(ColorGutterSignHint)
3834 }
3835 }
3836 }
3837 }
3838
3839 termbox.SetCell(0, screenY, diagSign, diagColor, diagBg)
3840 termbox.SetCell(1, screenY, ' ', diagBg, diagBg)
3841
3842 // Gutter line number rendering.
3843 lineNum := strconv.Itoa(bufferY + 1)
3844 gutterFg, gutterBg := GetThemeColor(ColorGutterLineNumber)
3845 for i, r := range lineNum {
3846 termbox.SetCell(Config.GutterWidth-len(lineNum)-1+i, screenY, r, gutterFg, gutterBg)
3847 }
3848
3849 // Text highlighting and rendering block.
3850 var fgAttrs []termbox.Attribute
3851 var bgAttrs []termbox.Attribute
3852 if b.fileType != nil && b.fileType.Name != "Default" {
3853 fgAttrs, bgAttrs = e.highlightLine(bufferY, b.buffer[bufferY])
3854 } else {
3855 fgAttrs = make([]termbox.Attribute, len(b.buffer[bufferY]))
3856 bgAttrs = make([]termbox.Attribute, len(b.buffer[bufferY]))
3857 for k := range fgAttrs {
3858 fgAttrs[k], bgAttrs[k] = GetThemeColor(ColorDefault)
3859 }
3860 }
3861
3862 _, bg := GetThemeColor(ColorDefault)
3863 if bufferY == b.PrimaryCursor().Y {
3864 _, bg = GetThemeColor(ColorHighlightedLine)
3865 for x := 0; x < textWidth; x++ {
3866 fg, _ := GetThemeColor(ColorDefault)
3867 termbox.SetCell(x+Config.GutterWidth, screenY, ' ', fg, bg)
3868 }
3869 }
3870
3871 inVisual := e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock
3872 var vStartY, vStartX, vEndY, vEndX int
3873 if inVisual {
3874 y1, x1, y2, x2 := e.getSelectionBounds()
3875 vStartY, vStartX, vEndY, vEndX = y1, x1, y2, x2
3876 if e.mode == ModeVisualBlock {
3877 if vStartX > vEndX {
3878 vStartX, vEndX = vEndX, vStartX
3879 }
3880 }
3881 }
3882
3883 searchMatches := []bool{}
3884 if e.lastSearch != "" {
3885 searchMatches = make([]bool, len(b.buffer[bufferY]))
3886 lineRunes := b.buffer[bufferY]
3887 queryRunes := []rune(strings.ToLower(e.lastSearch))
3888 queryLen := len(queryRunes)
3889
3890 for i := 0; i <= len(lineRunes)-queryLen; i++ {
3891 match := true
3892 for j := 0; j < queryLen; j++ {
3893 if unicode.ToLower(lineRunes[i+j]) != queryRunes[j] {
3894 match = false
3895 break
3896 }
3897 }
3898 if match {
3899 for k := 0; k < queryLen; k++ {
3900 searchMatches[i+k] = true
3901 }
3902 }
3903 }
3904 }
3905
3906 visualX := 0
3907 for idx, r := range b.buffer[bufferY] {
3908 width := e.visualWidth(r, visualX)
3909
3910 charBg := bg
3911 isVisualSelected := false
3912 if inVisual {
3913 if e.mode == ModeVisualBlock {
3914 if bufferY >= vStartY && bufferY <= vEndY {
3915 if idx >= vStartX && idx < vEndX+1 {
3916 isVisualSelected = true
3917 }
3918 }
3919 } else if e.mode == ModeVisualLine {
3920 if bufferY >= vStartY && bufferY <= vEndY {
3921 isVisualSelected = true
3922 }
3923 } else {
3924 if bufferY > vStartY && bufferY < vEndY {
3925 isVisualSelected = true
3926 } else if bufferY == vStartY && bufferY == vEndY {
3927 if idx >= vStartX && idx < vEndX+1 {
3928 isVisualSelected = true
3929 }
3930 } else if bufferY == vStartY {
3931 if idx >= vStartX {
3932 isVisualSelected = true
3933 }
3934 } else if bufferY == vEndY {
3935 if idx < vEndX+1 {
3936 isVisualSelected = true
3937 }
3938 }
3939 }
3940 }
3941
3942 isCursor := false
3943 if cm, ok := cursorMap[bufferY]; ok {
3944 if cm[idx] {
3945 isCursor = true
3946 }
3947 }
3948
3949 if isVisualSelected {
3950 selFg, selBg := GetThemeColor(ColorVisualModeSelection)
3951 charBg = selBg
3952 if idx < len(fgAttrs) {
3953 fgAttrs[idx] = selFg
3954 }
3955 }
3956
3957 if isCursor {
3958 charBg, fgAttrs[idx] = fgAttrs[idx], charBg
3959 if charBg == fgAttrs[idx] {
3960 _, forcedBg := GetThemeColor(ColorCursor)
3961 charBg = forcedBg
3962 fgAttrs[idx] = termbox.ColorWhite
3963 }
3964 }
3965
3966 if !isVisualSelected && len(searchMatches) > idx && searchMatches[idx] {
3967 searchMatchFg, searchMatchBg := GetThemeColor(ColorSearchMatch)
3968 charBg = searchMatchBg
3969 fgAttrs[idx] = searchMatchFg
3970 }
3971
3972 if e.mode == ModeReplace {
3973 for _, match := range e.replaceMatches {
3974 if match.startLine == bufferY && idx >= match.startCol && idx < match.endCol {
3975 replaceMatchFg, replaceMatchBg := GetThemeColor(ColorReplaceMatch)
3976 charBg = replaceMatchBg
3977 fgAttrs[idx] = replaceMatchFg
3978 break
3979 }
3980 }
3981 }
3982
3983 if !isVisualSelected && charBg == bg && len(bgAttrs) > idx && bgAttrs[idx] != defaultBg {
3984 charBg = bgAttrs[idx]
3985 }
3986
3987 for i := 0; i < width; i++ {
3988 screenX := visualX + i - b.scrollX
3989 if screenX >= 0 && screenX < textWidth {
3990 char := r
3991 if r == '\t' {
3992 char = ' '
3993 }
3994 termbox.SetCell(screenX+Config.GutterWidth, screenY, char, fgAttrs[idx], charBg)
3995 }
3996 }
3997 visualX += width
3998 }
3999
4000 if e.mode == ModeVisualLine && bufferY >= vStartY && bufferY <= vEndY {
4001 _, visualModeLineBg := GetThemeColor(ColorVisualModeSelection)
4002 for x := visualX - b.scrollX; x < textWidth; x++ {
4003 if x >= 0 {
4004 termbox.SetCell(x+Config.GutterWidth, screenY, ' ', termbox.ColorDefault, visualModeLineBg)
4005 }
4006 }
4007 }
4008 } else {
4009 fg, bg := GetThemeColor(ColorEmptyLineMarker)
4010 termbox.SetCell(0, screenY, '~', fg, bg)
4011 }
4012 }
4013
4014 if !e.introDismissed && b.filename == "" && len(b.buffer) == 1 && len(b.buffer[0]) == 0 && !b.modified && e.mode != ModeInsert {
4015 e.drawIntro()
4016 }
4017
4018 if e.mode == ModeFuzzy {
4019 statusY := h - 2 - Config.FuzzyFinderHeight
4020 fuzzyY := h - 1 - Config.FuzzyFinderHeight
4021 cmdY := h - 1
4022
4023 e.drawStatusBar(statusY)
4024 e.drawFuzzyFinder(fuzzyY, Config.FuzzyFinderHeight)
4025 e.drawCommandBar(cmdY)
4026 } else {
4027 e.drawStatusBar(h - 2)
4028 e.drawCommandBar(h - 1)
4029 }
4030
4031 if e.showDebugLog {
4032 e.drawDebugDiagnostics()
4033 }
4034
4035 if e.showHover {
4036 e.drawHoverPopup()
4037 }
4038
4039 if e.showAutocomplete {
4040 e.drawAutocompletePopup()
4041 }
4042
4043 // Synchronize terminal cursor with editor focus.
4044 if e.mode == ModeCommand {
4045 termbox.SetCursor(e.commandCursorX+1, h-1)
4046 } else if e.mode == ModeFuzzy {
4047 termbox.SetCursor(len(e.fuzzyBuffer)+3, h-1)
4048 } else if e.mode == ModeFind {
4049 termbox.SetCursor(len(e.findBuffer)+1, h-1)
4050 } else if e.mode == ModeReplace {
4051 termbox.SetCursor(len(e.replaceInput)+9, h-1)
4052 } else {
4053 termbox.SetCursor(visualCursorX-b.scrollX+Config.GutterWidth, b.PrimaryCursor().Y-b.scrollY)
4054 }
4055 termbox.Flush()
4056}
4057
4058func (e *Editor) drawDebugDiagnostics() {
4059 w, h := termbox.Size()
4060 b := e.activeBuffer()
4061 if b == nil {
4062 return
4063 }
4064
4065 startX := 0
4066 startY := h - 22
4067
4068 // Draw window background
4069 for y := startY; y < startY+w && y < h-2; y++ {
4070 for x := startX; x < w; x++ {
4071 fg, bg := GetThemeColor(ColorDebugWindow)
4072 termbox.SetCell(x, y, ' ', fg, bg)
4073 }
4074 }
4075
4076 // Draw window title
4077 title := "[DEBUG LOG]"
4078 titleX := startX + (w-len(title))/2
4079 for i, r := range title {
4080 fg, bg := GetThemeColor(ColorDebugTitle)
4081 termbox.SetCell(titleX+i, startY, r, fg, bg)
4082 }
4083
4084 // Prepare content lines
4085 contentLines := []string{}
4086
4087 // Add LSP status
4088 if b.lspClient != nil {
4089 contentLines = append(contentLines, fmt.Sprintf("LSP: Active (%s)", filepath.Base(b.filename)))
4090 contentLines = append(contentLines, fmt.Sprintf("Diags: %d", len(b.diagnostics)))
4091
4092 // Show first few diagnostics
4093 for i, diag := range b.diagnostics {
4094 if i >= 3 {
4095 contentLines = append(contentLines, " ...")
4096 break
4097 }
4098 sevStr := "?"
4099 switch diag.Severity {
4100 case 1:
4101 sevStr = "E"
4102 case 2:
4103 sevStr = "W"
4104 case 3:
4105 sevStr = "I"
4106 case 4:
4107 sevStr = "H"
4108 }
4109 msg := fmt.Sprintf(" [%s] L%d: %s", sevStr, diag.Range.Start.Line+1, diag.Message)
4110 if len(msg) > w-2 {
4111 msg = msg[:w-5] + "..."
4112 }
4113 contentLines = append(contentLines, msg)
4114 }
4115 contentLines = append(contentLines, "---")
4116 }
4117
4118 // Add recent log messages (last 8)
4119 startLog := 0
4120 if len(e.logMessages) > Config.NumLogsInDebugWindow {
4121 startLog = len(e.logMessages) - Config.NumLogsInDebugWindow
4122 }
4123 for i := startLog; i < len(e.logMessages); i++ {
4124 msg := e.logMessages[i]
4125 if len(msg) > w-2 {
4126 msg = msg[:w-5] + "..."
4127 }
4128 contentLines = append(contentLines, msg)
4129 }
4130
4131 // Draw content
4132 for i, line := range contentLines {
4133 if i >= w-2 {
4134 break
4135 }
4136 y := startY + 1 + i
4137 x := startX + 1
4138 for j, r := range line {
4139 if x+j >= w {
4140 break
4141 }
4142 fg, bg := GetThemeColor(ColorDebugWindow)
4143 termbox.SetCell(x+j, y, r, fg, bg)
4144 }
4145 }
4146}
4147
4148func (e *Editor) drawFuzzyFinder(startY int, fuzzyHeight int) {
4149 w, _ := termbox.Size()
4150
4151 // Draw results
4152 for i := 0; i < fuzzyHeight; i++ {
4153 resultIdx := i + e.fuzzyScroll
4154 if resultIdx >= len(e.fuzzyResults) {
4155 break
4156 }
4157
4158 file := e.fuzzyResults[resultIdx]
4159 y := startY + fuzzyHeight - 1 - i
4160 fg, bg := GetThemeColor(ColorFuzzyResult)
4161
4162 if resultIdx == e.fuzzyIndex {
4163 // Highlight the entire selected line
4164 selFg, selBg := GetThemeColor(ColorFuzzySelected)
4165 for x := 0; x < w; x++ {
4166 termbox.SetCell(x, y, ' ', selFg, selBg)
4167 }
4168 fg, bg = selFg, selBg
4169 file = " > " + file
4170 } else {
4171 file = " " + file
4172 }
4173
4174 for x, r := range file {
4175 if x < w {
4176 termbox.SetCell(x, y, r, fg, bg)
4177 }
4178 }
4179 }
4180}
4181
4182func (e *Editor) centerScreen() {
4183 b := e.activeBuffer()
4184 if b == nil {
4185 return
4186 }
4187 _, h := termbox.Size()
4188 visibleHeight := h - 2
4189
4190 // Calculate target scroll to center current line
4191 targetScrollY := b.PrimaryCursor().Y - (visibleHeight / 2)
4192
4193 // Don't scroll beyond buffer bounds
4194 if targetScrollY < 0 {
4195 targetScrollY = 0
4196 }
4197 if targetScrollY > len(b.buffer)-visibleHeight {
4198 targetScrollY = len(b.buffer) - visibleHeight
4199 }
4200 if targetScrollY < 0 {
4201 targetScrollY = 0
4202 }
4203
4204 b.scrollY = targetScrollY
4205}
4206
4207func (e *Editor) addCursorAbove() {
4208 b := e.activeBuffer()
4209 if b == nil {
4210 return
4211 }
4212 primary := b.PrimaryCursor()
4213 if primary.Y > 0 {
4214 b.AddCursor(primary.X, primary.Y-1)
4215 }
4216}
4217
4218func (e *Editor) addCursorBelow() {
4219 b := e.activeBuffer()
4220 if b == nil {
4221 return
4222 }
4223
4224 // Find the cursor with the highest Y (lowest visual position)
4225 maxY := -1
4226 targetX := -1
4227
4228 for _, c := range b.cursors {
4229 if c.Y > maxY {
4230 maxY = c.Y
4231 targetX = c.X
4232 // If this cursor has a preferred column, use that instead of current X
4233 // This helps when moving through shorter lines
4234 if c.PreferredCol > targetX {
4235 targetX = c.PreferredCol
4236 }
4237 }
4238 }
4239
4240 if maxY < len(b.buffer)-1 {
4241 b.AddCursor(targetX, maxY+1)
4242 }
4243}
4244
4245func (e *Editor) clearSecondaryCursors() {
4246 b := e.activeBuffer()
4247 if b == nil {
4248 return
4249 }
4250 b.ClearCursors()
4251}
4252
4253func (e *Editor) drawHoverPopup() {
4254 if !e.showHover || e.hoverContent == "" {
4255 return
4256 }
4257
4258 w, _ := termbox.Size()
4259 b := e.activeBuffer()
4260 if b == nil {
4261 return
4262 }
4263
4264 lines := strings.Split(e.hoverContent, "\n")
4265 maxWidth := 0
4266 for _, line := range lines {
4267 if len(line) > maxWidth {
4268 maxWidth = len(line)
4269 }
4270 }
4271
4272 // Cap width to terminal width
4273 if maxWidth > w-10 {
4274 maxWidth = w - 10
4275 }
4276
4277 paddingX := 2
4278 paddingY := 1
4279 popupWidth := maxWidth + (paddingX * 2)
4280 popupHeight := len(lines) + (paddingY * 2)
4281
4282 // Calculate position (above cursor)
4283 visualCursorX := e.bufferToVisual(b.buffer[b.PrimaryCursor().Y], b.PrimaryCursor().X)
4284 cursorScreenX := visualCursorX - b.scrollX + Config.GutterWidth
4285 cursorScreenY := b.PrimaryCursor().Y - b.scrollY
4286
4287 startX := cursorScreenX
4288 startY := cursorScreenY - popupHeight
4289
4290 // Adjust if out of bounds
4291 if startY < 0 {
4292 startY = cursorScreenY + 1
4293 }
4294 if startX+popupWidth > w {
4295 startX = w - popupWidth
4296 }
4297 if startX < 0 {
4298 startX = 0
4299 }
4300
4301 fg, bg := GetThemeColor(ColorHoverWindow)
4302 // Draw background and content
4303 for y := 0; y < popupHeight; y++ {
4304 for x := 0; x < popupWidth; x++ {
4305 termbox.SetCell(startX+x, startY+y, ' ', fg, bg)
4306 }
4307 }
4308
4309 // Draw content lines
4310 for i, line := range lines {
4311 if i >= len(lines) {
4312 break
4313 }
4314 y := startY + paddingY + i
4315 for j, r := range line {
4316 if j >= maxWidth {
4317 break
4318 }
4319 if startX+paddingX+j < w {
4320 termbox.SetCell(startX+paddingX+j, y, r, fg, bg)
4321 }
4322 }
4323 }
4324}
4325
4326// triggerHover initiates an LSP hover request for the current cursor position.
4327func (e *Editor) triggerHover() {
4328 b := e.activeBuffer()
4329 if b == nil || b.lspClient == nil {
4330 return
4331 }
4332
4333 e.message = "Requesting signature..."
4334 e.draw()
4335
4336 cursor := b.PrimaryCursor()
4337 content, err := b.lspClient.Hover(cursor.Y, cursor.X)
4338 if err != nil {
4339 e.message = fmt.Sprintf("LSP Hover error: %v", err)
4340 return
4341 }
4342
4343 e.hoverContent = content
4344 e.showHover = true
4345}
4346
4347// triggerAutocomplete initiates an LSP completion request for the current cursor position.
4348func (e *Editor) triggerAutocomplete() {
4349 b := e.activeBuffer()
4350 if b == nil || b.lspClient == nil {
4351 return
4352 }
4353
4354 e.message = "Requesting completions..."
4355 e.draw()
4356
4357 cursor := b.PrimaryCursor()
4358 items, err := b.lspClient.Completion(cursor.Y, cursor.X)
4359 if err != nil {
4360 e.message = fmt.Sprintf("LSP Completion error: %v", err)
4361 return
4362 }
4363
4364 if len(items) == 0 {
4365 e.message = "No completions available"
4366 return
4367 }
4368
4369 e.autocompleteItems = items
4370 e.autocompleteIndex = 0
4371 e.autocompleteScroll = 0
4372 e.showAutocomplete = true
4373 e.message = ""
4374}
4375
4376func (e *Editor) drawAutocompletePopup() {
4377 if !e.showAutocomplete || len(e.autocompleteItems) == 0 {
4378 return
4379 }
4380
4381 w, h := termbox.Size()
4382 b := e.activeBuffer()
4383 if b == nil {
4384 return
4385 }
4386
4387 // Calculate max label width for alignment
4388 maxLabelWidth := 0
4389 for _, item := range e.autocompleteItems {
4390 if len(item.Label) > maxLabelWidth {
4391 maxLabelWidth = len(item.Label)
4392 }
4393 }
4394
4395 // Calculate total width: label + separator + detail
4396 maxWidth := 0
4397 for _, item := range e.autocompleteItems {
4398 displayText := item.Label
4399 if item.Detail != "" {
4400 // Pad label to align, then add arrow and detail
4401 padding := maxLabelWidth - len(item.Label)
4402 displayText = item.Label + strings.Repeat(" ", padding) + " " + item.Detail
4403 }
4404 if len(displayText) > maxWidth {
4405 maxWidth = len(displayText)
4406 }
4407 }
4408
4409 // Cap width to terminal width
4410 if maxWidth > w-10 {
4411 maxWidth = w - 10
4412 }
4413
4414 popupWidth := maxWidth + 2
4415 popupHeight := len(e.autocompleteItems)
4416 if popupHeight > 10 {
4417 popupHeight = 10
4418 }
4419
4420 // Calculate position (below cursor or above if no space)
4421 visualCursorX := e.bufferToVisual(b.buffer[b.PrimaryCursor().Y], b.PrimaryCursor().X)
4422 cursorScreenX := visualCursorX - b.scrollX + Config.GutterWidth
4423 cursorScreenY := b.PrimaryCursor().Y - b.scrollY
4424
4425 startX := cursorScreenX
4426 startY := cursorScreenY + 1
4427
4428 // Adjust if out of bounds
4429 if startY+popupHeight > h-1 {
4430 startY = cursorScreenY - popupHeight
4431 }
4432 if startX+popupWidth > w {
4433 startX = w - popupWidth
4434 }
4435 if startX < 0 {
4436 startX = 0
4437 }
4438
4439 fg, bg := GetThemeColor(ColorAutocompleteWindow)
4440 selFg, selBg := GetThemeColor(ColorAutocompleteSelected)
4441
4442 // Draw background and content
4443 for y := 0; y < popupHeight; y++ {
4444 itemIdx := y + e.autocompleteScroll
4445 if itemIdx >= len(e.autocompleteItems) {
4446 break
4447 }
4448 item := e.autocompleteItems[itemIdx]
4449
4450 currentFg, currentBg := fg, bg
4451 if itemIdx == e.autocompleteIndex {
4452 currentFg, currentBg = selFg, selBg
4453 }
4454
4455 // Fill line
4456 for x := 0; x < popupWidth; x++ {
4457 termbox.SetCell(startX+x, startY+y, ' ', currentFg, currentBg)
4458 }
4459
4460 // Draw label and detail (signature) with alignment
4461 displayText := item.Label
4462 if item.Detail != "" {
4463 // Pad label to align with others
4464 padding := maxLabelWidth - len(item.Label)
4465 displayText = item.Label + strings.Repeat(" ", padding) + " " + item.Detail
4466 }
4467 if len(displayText) > maxWidth {
4468 displayText = displayText[:maxWidth-3] + "..."
4469 }
4470 for j, r := range displayText {
4471 termbox.SetCell(startX+1+j, startY+y, r, currentFg, currentBg)
4472 }
4473 }
4474}
4475
4476func (e *Editor) insertCompletion(item CompletionItem) {
4477 b := e.activeBuffer()
4478 if b == nil {
4479 return
4480 }
4481
4482 cursor := b.PrimaryCursor()
4483 line := b.buffer[cursor.Y]
4484
4485 // Find the start of the word we're completing
4486 start := cursor.X
4487 for start > 0 {
4488 r := line[start-1]
4489 if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') {
4490 break
4491 }
4492 start--
4493 }
4494
4495 // Text to insert
4496 insertText := item.InsertText
4497 if insertText == "" {
4498 insertText = item.Label
4499 }
4500
4501 // Check if this is a function/method (Kind 2=Method, 3=Function)
4502 // or if the Detail contains "func" indicating it's a function
4503 isFunction := item.Kind == 2 || item.Kind == 3 || strings.Contains(item.Detail, "func")
4504
4505 // Replace the prefix with the completion
4506 newRuneLine := make([]rune, start)
4507 copy(newRuneLine, line[:start])
4508 newRuneLine = append(newRuneLine, []rune(insertText)...)
4509
4510 // Add () for functions if not already present
4511 cursorOffset := len(insertText)
4512 if isFunction {
4513 // Check if next character is already (
4514 nextIdx := cursor.X
4515 if nextIdx >= len(line) || line[nextIdx] != '(' {
4516 newRuneLine = append(newRuneLine, '(', ')')
4517 cursorOffset++ // Position cursor inside the parentheses
4518 }
4519 }
4520
4521 newRuneLine = append(newRuneLine, line[cursor.X:]...)
4522
4523 b.buffer[cursor.Y] = newRuneLine
4524 cursor.X = start + cursorOffset
4525
4526 // Handle syntax update
4527 if b.syntax != nil {
4528 b.syntax.Reparse([]byte(b.toString()))
4529 }
4530
4531 e.markModified()
4532 e.showAutocomplete = false
4533}