diff --git a/editor.go b/editor.go index 1011c094908b2d235c8d72e882792951f97aaf99..ee63916894b67d50a09d3bafc5d357e7e42d5b4f 100644 --- a/editor.go +++ b/editor.go @@ -1080,6 +1080,261 @@ } return indent } +func (e *Editor) indentLine(y int) { + b := e.activeBuffer() + if b == nil || y < 0 || y >= len(b.buffer) { + return + } + + tabWidth := Config.DefaultTabWidth + if b.fileType != nil { + tabWidth = b.fileType.TabWidth + } + + var indentRunes []rune + if e.useTabs() { + indentRunes = []rune{'\t'} + } else { + indentRunes = []rune(strings.Repeat(" ", tabWidth)) + } + + line := b.buffer[y] + newLine := append(indentRunes, line...) + b.buffer[y] = newLine + + // Shift cursors on this line + for i := range b.cursors { + if b.cursors[i].Y == y { + b.cursors[i].X += len(indentRunes) + b.cursors[i].PreferredCol = b.cursors[i].X + } + } + + // Shift visual anchor if it's on this line + if (e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock) && e.visualStartY == y { + e.visualStartX += len(indentRunes) + } +} + +func (e *Editor) unindentLine(y int) { + b := e.activeBuffer() + if b == nil || y < 0 || y >= len(b.buffer) { + return + } + + line := b.buffer[y] + if len(line) == 0 { + return + } + + tabWidth := Config.DefaultTabWidth + if b.fileType != nil { + tabWidth = b.fileType.TabWidth + } + + removedCount := 0 + if line[0] == '\t' { + removedCount = 1 + } else if line[0] == ' ' { + removedCount = 0 + for removedCount < tabWidth && removedCount < len(line) && line[removedCount] == ' ' { + removedCount++ + } + } + + if removedCount > 0 { + b.buffer[y] = line[removedCount:] + // Shift cursors on this line + for i := range b.cursors { + if b.cursors[i].Y == y { + b.cursors[i].X -= removedCount + if b.cursors[i].X < 0 { + b.cursors[i].X = 0 + } + b.cursors[i].PreferredCol = b.cursors[i].X + } + } + + // Shift visual anchor if it's on this line + if (e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock) && e.visualStartY == y { + e.visualStartX -= removedCount + if e.visualStartX < 0 { + e.visualStartX = 0 + } + } + } +} + +func (e *Editor) Indent() { + e.IndentSelection(false) +} + +func (e *Editor) IndentSelection(stayInMode bool) { + b := e.activeBuffer() + if b == nil { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + e.saveState() + + if e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock { + y1, _, y2, _ := e.getSelectionBounds() + for y := y1; y <= y2; y++ { + e.indentLine(y) + } + if !stayInMode { + e.mode = ModeNormal + } + } else { + // Indent lines with cursors + lines := make(map[int]bool) + for _, c := range b.cursors { + lines[c.Y] = true + } + for y := range lines { + e.indentLine(y) + } + } + + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +func (e *Editor) Unindent() { + e.UnindentSelection(false) +} + +func (e *Editor) UnindentSelection(stayInMode bool) { + b := e.activeBuffer() + if b == nil { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + e.saveState() + + if e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock { + y1, _, y2, _ := e.getSelectionBounds() + for y := y1; y <= y2; y++ { + e.unindentLine(y) + } + if !stayInMode { + e.mode = ModeNormal + } + } else { + // Unindent lines with cursors + lines := make(map[int]bool) + for _, c := range b.cursors { + lines[c.Y] = true + } + for y := range lines { + e.unindentLine(y) + } + } + + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +func (e *Editor) MoveLinesUp() { + e.moveLines(-1) +} + +func (e *Editor) MoveLinesDown() { + e.moveLines(1) +} + +func (e *Editor) moveLines(dy int) { + b := e.activeBuffer() + if b == nil || b.readOnly || len(b.buffer) == 0 { + return + } + + e.saveState() + + var y1, y2 int + if e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock { + y1, _, y2, _ = e.getSelectionBounds() + } else { + y1 = b.PrimaryCursor().Y + y2 = b.PrimaryCursor().Y + for _, c := range b.cursors { + if c.Y < y1 { + y1 = c.Y + } + if c.Y > y2 { + y2 = c.Y + } + } + } + + if dy == -1 && y1 > 0 { + // Move block up: swap y1-1 with the block [y1, y2] + lineAbove := b.buffer[y1-1] + for y := y1; y <= y2; y++ { + b.buffer[y-1] = b.buffer[y] + } + b.buffer[y2] = lineAbove + + // Update cursors + for i := range b.cursors { + if b.cursors[i].Y >= y1 && b.cursors[i].Y <= y2 { + b.cursors[i].Y-- + } else if b.cursors[i].Y == y1-1 { + b.cursors[i].Y += (y2 - y1 + 1) + } + } + // Update visual anchor + if e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock { + if e.visualStartY >= y1 && e.visualStartY <= y2 { + e.visualStartY-- + } else if e.visualStartY == y1-1 { + e.visualStartY += (y2 - y1 + 1) + } + } + } else if dy == 1 && y2 < len(b.buffer)-1 { + // Move block down: swap y2+1 with the block [y1, y2] + lineBelow := b.buffer[y2+1] + for y := y2; y >= y1; y-- { + b.buffer[y+1] = b.buffer[y] + } + b.buffer[y1] = lineBelow + + // Update cursors + for i := range b.cursors { + if b.cursors[i].Y >= y1 && b.cursors[i].Y <= y2 { + b.cursors[i].Y++ + } else if b.cursors[i].Y == y2+1 { + b.cursors[i].Y -= (y2 - y1 + 1) + } + } + // Update visual anchor + if e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock { + if e.visualStartY >= y1 && e.visualStartY <= y2 { + e.visualStartY++ + } else if e.visualStartY == y2+1 { + e.visualStartY -= (y2 - y1 + 1) + } + } + } + + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + // insertNewline breaks the line at cursor and handles auto-indentation. func (e *Editor) insertNewline() { b := e.activeBuffer() diff --git a/kevent.go b/kevent.go index 254694115c353f79576cb38765e9da442d179fe1..e0aef2b6d830a59a659b2dbb5a53f757c51ca755 100644 --- a/kevent.go +++ b/kevent.go @@ -5,15 +5,34 @@ // keyboard/mouse events to mode-specific handlers (Normal, Insert, Visual, // etc.). import ( + "time" + "github.com/nsf/termbox-go" ) +const ( + seqAltArrowUp = "[1;3A" + seqAltArrowDown = "[1;3B" + seqAltArrowRight = "[1;3C" + seqAltArrowLeft = "[1;3D" +) + // HandleEvents is the central loop that waits for and processes all user input. func (e *Editor) HandleEvents() { + eventChan := make(chan termbox.Event) + go func() { + for { + eventChan <- termbox.PollEvent() + } + }() + for { // Redraw the screen before waiting for the next event. e.draw() - ev := termbox.PollEvent() + var ev termbox.Event + select { + case ev = <-eventChan: + } // Handle interrupt events (triggered by diagnostic updates). // Fetch latest diagnostics from LSP client. @@ -39,35 +58,124 @@ if ev.Key == termbox.KeyCtrlC && e.devMode { return } - // Dispatch the key event to the handler for the current editor mode. - switch e.mode { - case ModeNormal: - e.handleNormalMode(ev) - case ModeInsert: - e.handleInsertMode(ev) - case ModeCommand: - e.handleCommandMode(ev) - case ModeFuzzy: - e.handleFuzzyMode(ev) - case ModeFind: - e.handleFindMode(ev) - case ModeVisual: - e.handleVisualMode(ev) - case ModeVisualLine: - e.handleVisualLineMode(ev) - case ModeVisualBlock: - e.handleVisualBlockMode(ev) - case ModeReplace: - e.handleReplaceMode(ev) - case ModeConfirm: - e.handleConfirmMode(ev) + // Special handling for ESC sequences (Alt+Arrows) in InputEsc mode. + if ev.Key == termbox.KeyEsc { + seq := "" + timer := time.NewTimer(30 * time.Millisecond) + matched := false + processed := false + seqLoop: + for { + select { + case nextEv := <-eventChan: + if nextEv.Type == termbox.EventKey { + if nextEv.Key != 0 { + // Some functional key followed ESC + if nextEv.Key == termbox.KeyArrowLeft { + ev = termbox.Event{Type: termbox.EventKey, Key: termbox.KeyArrowLeft, Mod: termbox.ModAlt} + matched = true + } else if nextEv.Key == termbox.KeyArrowRight { + ev = termbox.Event{Type: termbox.EventKey, Key: termbox.KeyArrowRight, Mod: termbox.ModAlt} + matched = true + } else if nextEv.Key == termbox.KeyArrowUp { + ev = termbox.Event{Type: termbox.EventKey, Key: termbox.KeyArrowUp, Mod: termbox.ModAlt} + matched = true + } else if nextEv.Key == termbox.KeyArrowDown { + ev = termbox.Event{Type: termbox.EventKey, Key: termbox.KeyArrowDown, Mod: termbox.ModAlt} + matched = true + } else { + // Not a known Alt+Arrow, process ESC then this key + e.dispatchEvent(ev) + ev = nextEv + } + break seqLoop + } else { + seq += string(nextEv.Ch) + if seq == seqAltArrowLeft { + ev = termbox.Event{Type: termbox.EventKey, Key: termbox.KeyArrowLeft, Mod: termbox.ModAlt} + matched = true + break seqLoop + } + if seq == seqAltArrowRight { + ev = termbox.Event{Type: termbox.EventKey, Key: termbox.KeyArrowRight, Mod: termbox.ModAlt} + matched = true + break seqLoop + } + if seq == seqAltArrowUp { + ev = termbox.Event{Type: termbox.EventKey, Key: termbox.KeyArrowUp, Mod: termbox.ModAlt} + matched = true + break seqLoop + } + if seq == seqAltArrowDown { + ev = termbox.Event{Type: termbox.EventKey, Key: termbox.KeyArrowDown, Mod: termbox.ModAlt} + matched = true + break seqLoop + } + if len(seq) > 5 { + // Sequence too long, process as individual keys + e.dispatchEvent(ev) + for _, r := range seq { + e.dispatchEvent(termbox.Event{Type: termbox.EventKey, Ch: r}) + } + processed = true + break seqLoop + } + } + } else { + // Not a key event, process ESC then this event + e.dispatchEvent(ev) + ev = nextEv + break seqLoop + } + case <-timer.C: + break seqLoop + } + } + if processed { + continue + } + if !matched && seq != "" { + e.dispatchEvent(ev) + for _, r := range seq { + e.dispatchEvent(termbox.Event{Type: termbox.EventKey, Ch: r}) + } + continue + } } + + e.dispatchEvent(ev) } else if ev.Type == termbox.EventMouse { e.handleMouseEvent(ev) } } } +func (e *Editor) dispatchEvent(ev termbox.Event) { + // Dispatch the key event to the handler for the current editor mode. + switch e.mode { + case ModeNormal: + e.handleNormalMode(ev) + case ModeInsert: + e.handleInsertMode(ev) + case ModeCommand: + e.handleCommandMode(ev) + case ModeFuzzy: + e.handleFuzzyMode(ev) + case ModeFind: + e.handleFindMode(ev) + case ModeVisual: + e.handleVisualMode(ev) + case ModeVisualLine: + e.handleVisualLineMode(ev) + case ModeVisualBlock: + e.handleVisualBlockMode(ev) + case ModeReplace: + e.handleReplaceMode(ev) + case ModeConfirm: + e.handleConfirmMode(ev) + } +} + // handleNormalMode processes keyboard input when the editor is in Normal mode. func (e *Editor) handleNormalMode(ev termbox.Event) { // Escape clears any pending multi-key commands or secondary cursors. @@ -85,17 +193,29 @@ } switch ev.Key { case termbox.KeyArrowLeft: - e.moveCursor(-1, 0) + if ev.Mod&termbox.ModAlt != 0 { + e.UnindentSelection(true) + } else { + e.moveCursor(-1, 0) + } case termbox.KeyArrowRight: - e.moveCursor(1, 0) + if ev.Mod&termbox.ModAlt != 0 { + e.IndentSelection(true) + } else { + e.moveCursor(1, 0) + } case termbox.KeyArrowUp: - if ev.Mod != 0 { + if ev.Mod&termbox.ModAlt != 0 { + e.MoveLinesUp() + } else if ev.Mod != 0 { e.addCursorAbove() } else { e.moveCursor(0, -1) } case termbox.KeyArrowDown: - if ev.Mod != 0 { + if ev.Mod&termbox.ModAlt != 0 { + e.MoveLinesDown() + } else if ev.Mod != 0 { e.addCursorBelow() } else { e.moveCursor(0, 1) @@ -123,6 +243,10 @@ } // Prevent key event fallthrough. if ev.Key != 0 { + return + } + + if ev.Mod&termbox.ModAlt != 0 { return } @@ -249,6 +373,20 @@ e.pendingKey = 0 } else { e.pendingKey = 'd' } + case '>': + if e.pendingKey == '>' { + e.Indent() + e.pendingKey = 0 + } else { + e.pendingKey = '>' + } + case '<': + if e.pendingKey == '<' { + e.Unindent() + e.pendingKey = 0 + } else { + e.pendingKey = '<' + } case 'y': e.yankLine() e.message = "Line yanked" @@ -493,7 +631,7 @@ case termbox.KeyCtrlN: e.triggerAutocomplete() default: // If a character key was pressed, insert the character. - if ev.Ch != 0 { + if ev.Ch != 0 && ev.Mod&termbox.ModAlt == 0 { e.insertRune(ev.Ch) // Close autocomplete if user keeps typing. if e.showAutocomplete { @@ -553,7 +691,7 @@ case termbox.KeyArrowDown: // Navigate to next command in history e.commands.NavigateHistoryDown() default: - if ev.Ch != 0 { + if ev.Ch != 0 && ev.Mod&termbox.ModAlt == 0 { // Insert character at cursor position e.commandBuffer = append(e.commandBuffer[:e.commandCursorX], append([]rune{ev.Ch}, e.commandBuffer[e.commandCursorX:]...)...) e.commandCursorX++ @@ -584,7 +722,7 @@ e.fuzzyBuffer = append(e.fuzzyBuffer, ' ') e.updateFuzzyResults() default: // Update filter as user types. - if ev.Ch != 0 { + if ev.Ch != 0 && ev.Mod&termbox.ModAlt == 0 { e.fuzzyBuffer = append(e.fuzzyBuffer, ev.Ch) e.updateFuzzyResults() } @@ -619,7 +757,7 @@ e.findBuffer = append(e.findBuffer, ' ') e.lastSearch = string(e.findBuffer) default: // Incremental search: update e.lastSearch as the user types. - if ev.Ch != 0 { + if ev.Ch != 0 && ev.Mod&termbox.ModAlt == 0 { e.findBuffer = append(e.findBuffer, ev.Ch) e.lastSearch = string(e.findBuffer) } @@ -636,13 +774,29 @@ } switch ev.Key { case termbox.KeyArrowLeft: - e.moveCursor(-1, 0) + if ev.Mod&termbox.ModAlt != 0 { + e.UnindentSelection(true) + } else { + e.moveCursor(-1, 0) + } case termbox.KeyArrowRight: - e.moveCursor(1, 0) + if ev.Mod&termbox.ModAlt != 0 { + e.IndentSelection(true) + } else { + e.moveCursor(1, 0) + } case termbox.KeyArrowUp: - e.moveCursor(0, -1) + if ev.Mod&termbox.ModAlt != 0 { + e.MoveLinesUp() + } else { + e.moveCursor(0, -1) + } case termbox.KeyArrowDown: - e.moveCursor(0, 1) + if ev.Mod&termbox.ModAlt != 0 { + e.MoveLinesDown() + } else { + e.moveCursor(0, 1) + } } // Prevent key event fallthrough. @@ -650,6 +804,10 @@ if ev.Key != 0 { return } + if ev.Mod&termbox.ModAlt != 0 { + return + } + switch ev.Ch { case 'J': e.saveState() @@ -675,6 +833,10 @@ e.saveState() e.deleteVisualSelection() e.checkDiagnostics() e.message = "Selection deleted" + case '>': + e.Indent() + case '<': + e.Unindent() case 'x': if e.pendingKey == 'z' { e.saveState() @@ -745,17 +907,37 @@ } switch ev.Key { case termbox.KeyArrowLeft: - e.moveCursor(-1, 0) + if ev.Mod&termbox.ModAlt != 0 { + e.UnindentSelection(true) + } else { + e.moveCursor(-1, 0) + } case termbox.KeyArrowRight: - e.moveCursor(1, 0) + if ev.Mod&termbox.ModAlt != 0 { + e.IndentSelection(true) + } else { + e.moveCursor(1, 0) + } case termbox.KeyArrowUp: - e.moveCursor(0, -1) + if ev.Mod&termbox.ModAlt != 0 { + e.MoveLinesUp() + } else { + e.moveCursor(0, -1) + } case termbox.KeyArrowDown: - e.moveCursor(0, 1) + if ev.Mod&termbox.ModAlt != 0 { + e.MoveLinesDown() + } else { + e.moveCursor(0, 1) + } } // Prevent key event fallthrough. if ev.Key != 0 { + return + } + + if ev.Mod&termbox.ModAlt != 0 { return } @@ -784,6 +966,10 @@ e.saveState() e.deleteVisualSelection() e.checkDiagnostics() e.message = "Selection deleted" + case '>': + e.Indent() + case '<': + e.Unindent() case 'x': if e.pendingKey == 'z' { e.saveState() @@ -853,17 +1039,37 @@ } switch ev.Key { case termbox.KeyArrowLeft: - e.moveCursor(-1, 0) + if ev.Mod&termbox.ModAlt != 0 { + e.UnindentSelection(true) + } else { + e.moveCursor(-1, 0) + } case termbox.KeyArrowRight: - e.moveCursor(1, 0) + if ev.Mod&termbox.ModAlt != 0 { + e.IndentSelection(true) + } else { + e.moveCursor(1, 0) + } case termbox.KeyArrowUp: - e.moveCursor(0, -1) + if ev.Mod&termbox.ModAlt != 0 { + e.MoveLinesUp() + } else { + e.moveCursor(0, -1) + } case termbox.KeyArrowDown: - e.moveCursor(0, 1) + if ev.Mod&termbox.ModAlt != 0 { + e.MoveLinesDown() + } else { + e.moveCursor(0, 1) + } } // Prevent key event fallthrough. if ev.Key != 0 { + return + } + + if ev.Mod&termbox.ModAlt != 0 { return } @@ -892,6 +1098,10 @@ e.saveState() e.deleteVisualSelection() e.checkDiagnostics() e.message = "Selection deleted" + case '>': + e.Indent() + case '<': + e.Unindent() case 'x': if e.pendingKey == 'z' { e.saveState() @@ -981,6 +1191,10 @@ } // Prevent key event fallthrough. if ev.Key != 0 { + return + } + + if ev.Mod&termbox.ModAlt != 0 { return } diff --git a/replace.go b/replace.go index f2c71c51ea103021ad7c8f05df51c5b3c8201387..593f29e9a3cbadcbbbf81cf14d12ebfb22e8198d 100644 --- a/replace.go +++ b/replace.go @@ -75,7 +75,7 @@ case termbox.KeySpace: e.replaceInput = append(e.replaceInput, ' ') e.updateReplacePreview() default: - if ev.Ch != 0 { + if ev.Ch != 0 && ev.Mod&termbox.ModAlt == 0 { e.replaceInput = append(e.replaceInput, ev.Ch) e.updateReplacePreview() // Live preview of matches as user types. }