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}