diff --git a/internal/action/interface.go b/internal/action/interface.go index 2ecb910..0795f39 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -6,44 +6,29 @@ import ( // Model defines the interface for editor state that actions can modify type Model interface { - // Text buffer - Lines() []string - Line(idx int) string - SetLine(idx int, content string) - InsertLine(idx int, content string) - DeleteLine(idx int) - LineCount() int - - // Cursor - CursorX() int - CursorY() int - SetCursorX(x int) - SetCursorY(y int) - ClampCursorX() - - // Windows + // ================================================== + // Core Data Access + // ================================================== Windows() []*Window - ActiveWindowId() int ActiveWindow() *Window + Buffers() []*Buffer + ActiveBuffer() *Buffer - // Window - ScrollY() int - SetScrollY(y int) - WinH() int - WinW() int - ViewPortH() int - - // Anchor - AnchorX() int - AnchorY() int - SetAnchorX(x int) - SetAnchorY(y int) - - // Insert + // ================================================== + // Insert Mode State + // ================================================== InsertKeys() []string SetInsertKeys(keys []string) - // Command mode + // Insert recording (for count replay) + SetInsertRecording(count int, action Action) + + // ExitInsertMode handles replay, cursor step-back, and mode transition on esc + ExitInsertMode() + + // ================================================== + // Command Mode State + // ================================================== Command() string SetCommand(cmd string) CommandCursor() int @@ -53,25 +38,54 @@ type Model interface { CommandOutput() string SetCommandOutput(out string) - // Settings + // ================================================== + // Editor-wide State + // ================================================== + Mode() Mode + SetMode(mode Mode) + Settings() Settings SetSettings(s Settings) + // ================================================== // Registers + // ================================================== Registers() map[rune]Register GetRegister(name rune) (Register, bool) SetRegister(name rune, t RegisterType, cnt []string) error UpdateDefaultRegister(t RegisterType, cnt []string) - // Mode - Mode() Mode - SetMode(mode Mode) + // ================================================== + // Depreciated + // ================================================== + // Text buffer + // Lines() []string + // Line(idx int) string + // SetLine(idx int, content string) + // InsertLine(idx int, content string) + // DeleteLine(idx int) + // LineCount() int - // Insert recording (for count replay) - SetInsertRecording(count int, action Action) + // Cursor + // CursorX() int + // CursorY() int + // SetCursorX(x int) + // SetCursorY(y int) + // ClampCursorX() + + // Window + // ScrollY() int + // SetScrollY(y int) + // WinH() int + // WinW() int + // ViewPortH() int + // + // Anchor + // AnchorX() int + // AnchorY() int + // SetAnchorX(x int) + // SetAnchorY(y int) - // ExitInsertMode handles replay, cursor step-back, and mode transition on esc - ExitInsertMode() } // Position represents a location in the buffer diff --git a/internal/action/window.go b/internal/action/window.go index 28ac87f..a003949 100644 --- a/internal/action/window.go +++ b/internal/action/window.go @@ -5,6 +5,7 @@ type WinOptions struct { // Number bool // Wrap bool // Relnumber bool + ScrollOff int } type Window struct { @@ -12,33 +13,73 @@ type Window struct { Number int // Ignored for now, will be used when splits come into play Buffer *Buffer - Cursor Position + Cursor Position // DO NOT MODIFY DIRECTLY, USE SETTERS Anchor Position ScrollY int Height int Width int - // Folds // TODO - // Options WinOptions + // Folds TODO + Options WinOptions } // ================================================== // Helper methods // ================================================== -// Window.ClampCursorX: Clamps the cursor in the X direction to ensure the cursor +// Window.ClampCursor: Clamps the cursor in the all directions to ensure the cursor // does not go into an invalid position. Such as negative values or past the end of -// the line. -func (w *Window) ClampCursorX() { - lineLen := len(w.Buffer.Lines[w.Cursor.Line]) - if lineLen == 0 { +// the line. In the Y direction it validates that the cursor does not pass the end +// of the content or attempt to be "above" the content (negative value). +func (w *Window) clampCursor() { + // Clamp line to valid range [0, lineCount-1] + maxLine := w.Buffer.LineCount() - 1 + if maxLine < 0 { + maxLine = 0 // Empty buffer edge case + } + if w.Cursor.Line < 0 { + w.Cursor.Line = 0 + } else if w.Cursor.Line > maxLine { + w.Cursor.Line = maxLine + } + + // Clamp column to valid range [0, lineLen] + lineLen := len(w.Buffer.Lines[w.Cursor.Line]) // Safe now - Line is valid + if w.Cursor.Col < 0 { + w.Cursor.Col = 0 + } else if lineLen == 0 { w.Cursor.Col = 0 } else if w.Cursor.Col >= lineLen { - w.Cursor.Col = lineLen + w.Cursor.Col = lineLen // Allow cursor after last char (insert mode) } } +// Window.AdjustScroll ensures the cursor stays within the height with scrollOff margins. +// Call this after any cursor movement. +func (w *Window) AdjustScroll() { + if w.Height <= 0 { + return + } + + // Effective scrollOff (can't be more than half the viewport) + off := min(w.Options.ScrollOff, w.Height/2) + + // Cursor too close to top — scroll up + if w.Cursor.Line < w.ScrollY+off { + w.ScrollY = w.Cursor.Line - off + } + + // Cursor too close to bottom — scroll down + if w.Cursor.Line > w.ScrollY+w.Height-1-off { + w.ScrollY = w.Cursor.Line - w.Height + 1 + off + } + + // Clamp scrollY to valid range + maxScroll := max(0, w.Buffer.LineCount()-w.Height) + w.ScrollY = max(0, min(w.ScrollY, maxScroll)) +} + // ================================================== // Setters // ================================================== @@ -58,16 +99,19 @@ func (w *Window) SetBuffer(buffer *Buffer) { // Window.SetCursor: Sets the cursor position in this window to the given position. func (w *Window) SetCursor(cursor Position) { w.Cursor = cursor + w.clampCursor() } // Window.SetCursorLine: Sets the line number of the cursor position. func (w *Window) SetCursorLine(line int) { w.Cursor.Line = line + w.clampCursor() } // Window.SetCursorCol: Sets the column number of the cursor position. func (w *Window) SetCursorCol(col int) { w.Cursor.Col = col + w.clampCursor() } // Window.SetCursorPos: Sets both the line and column of the cursor position. This is @@ -75,6 +119,7 @@ func (w *Window) SetCursorCol(col int) { func (w *Window) SetCursorPos(line, col int) { w.Cursor.Line = line w.Cursor.Col = col + w.clampCursor() } // Window.SetAnchor: Sets the anchor position in this window. The anchor is used for diff --git a/internal/action/window_builder.go b/internal/action/window_builder.go index a213103..9f507f3 100644 --- a/internal/action/window_builder.go +++ b/internal/action/window_builder.go @@ -20,6 +20,9 @@ func NewWindowBuilder() *WindowBuilder { ScrollY: 0, Height: 0, Width: 0, + Options: WinOptions{ + ScrollOff: 8, // 8 is default + }, }, } } @@ -92,6 +95,13 @@ func (w *WindowBuilder) WithDimensions(width, height int) *WindowBuilder { return w } +// WindowBuilder.WithOptions: Applies the options to the window that is being built. +// This is a convenience method for setting all options in one call. +func (w *WindowBuilder) WithOptions(options WinOptions) *WindowBuilder { + w.window.Options = options + return w +} + // WindowBuilder.Build: Build the final window and return it to the caller. Final // step in the process. This is where the ID is set, so many windows can be "in-progress" // but the ID will be set when they are built. Meaning, this is not thread safe. diff --git a/internal/editor/model.go b/internal/editor/model.go index 398c0f2..d1c068f 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -77,84 +77,43 @@ func NewModel(lines []string, pos action.Position) *Model { return &m } +// Model.Init: Initialize the model and start any commands that may need to run. Required +// for the bubbletea architecture. func (m Model) Init() tea.Cmd { return nil } // Implement action.Model interface -func (m *Model) Lines() []string { - win := m.ActiveWindow() - return win.Buffer.Lines +// ================================================== +// Core Data Access +// ================================================== +func (m *Model) Windows() []*action.Window { + return m.windows } -func (m *Model) Line(idx int) string { - win := m.ActiveWindow() - return win.Buffer.Line(idx) +func (m *Model) ActiveWindow() *action.Window { + winId := m.activeWindowId + for i := range m.Windows() { + if m.windows[i].Id == winId { + return m.windows[i] + } + } + panic("Could not find window") } -func (m *Model) SetLine(idx int, content string) { - win := m.ActiveWindow() - win.Buffer.SetLine(idx, content) +func (m *Model) Buffers() []*action.Buffer { + return m.buffers } -func (m *Model) InsertLine(idx int, content string) { +func (m *Model) ActiveBuffer() *action.Buffer { win := m.ActiveWindow() - win.Buffer.InsertLine(idx, content) + return win.Buffer } -func (m *Model) DeleteLine(idx int) { - win := m.ActiveWindow() - win.Buffer.DeleteLine(idx) -} - -func (m *Model) LineCount() int { - win := m.ActiveWindow() - return win.Buffer.LineCount() -} - -func (m *Model) CursorX() int { - win := m.ActiveWindow() - return win.Cursor.Col -} - -func (m *Model) CursorY() int { - win := m.ActiveWindow() - return win.Cursor.Line -} - -func (m *Model) SetCursorX(x int) { - win := m.ActiveWindow() - win.Cursor.Col = x -} - -func (m *Model) SetCursorY(y int) { - win := m.ActiveWindow() - win.Cursor.Line = y -} - -// Anchor methods -func (m *Model) AnchorX() int { - win := m.ActiveWindow() - return win.Anchor.Col -} - -func (m *Model) AnchorY() int { - win := m.ActiveWindow() - return win.Anchor.Line -} - -func (m *Model) SetAnchorX(x int) { - win := m.ActiveWindow() - win.Anchor.Col = x -} - -func (m *Model) SetAnchorY(y int) { - win := m.ActiveWindow() - win.Anchor.Line = y -} - -// Insert methods +// ================================================== +// Insert Mode Methods +// ================================================== func (m *Model) InsertKeys() []string { return m.insertKeys } @@ -163,7 +122,145 @@ func (m *Model) SetInsertKeys(keys []string) { m.insertKeys = keys } -// Command mode +func (m *Model) SetInsertRecording(count int, act action.Action) { + m.insertCount = count + m.insertKeys = []string{} + m.insertAction = act +} + +func (m *Model) ExitInsertMode() { + win := m.ActiveWindow() + if m.insertCount > 1 { + m.replayInsert() + } + if win.Cursor.Col > 0 { + win.Cursor.Col-- + } + m.mode = action.NormalMode + m.insertCount = 0 + m.insertKeys = nil +} + +func (m *Model) replayInsert() { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + // Replay (count - 1) more times + for i := 1; i < m.insertCount; i++ { + // For 'o' and 'O', we need to create a new line first + switch m.insertAction.(type) { + case action.OpenLineBelow: + pos := win.Cursor.Line + buf.InsertLine(pos+1, "") + win.SetCursorLine(pos + 1) + + case action.OpenLineAbove: + pos := win.Cursor.Line + buf.InsertLine(pos, "") + + // 'i' and 'a' don't need setup - just replay keys + } + + // Replay each recorded keystroke + for _, key := range m.insertKeys { + m.processInsertKey(key) + } + } +} + +// TODO: Fix this shitty shit shit shit +func (m *Model) processInsertKey(key string) { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + col := win.Cursor.Col + line := win.Cursor.Line + l := buf.Line(line) + + switch key { + case "enter": + if col == len(l) { + buf.InsertLine(line+1, "") + } else { + buf.SetLine(line, l[:col]) + buf.InsertLine(line+1, l[col:]) + } + win.SetCursorLine(line + 1) + win.SetCursorCol(0) + + case "backspace": + if col > 0 { + buf.SetLine(line, l[:col-1]+l[col:]) + win.SetCursorCol(col - 1) + } else if line > 0 { + prevLine := buf.Line(line - 1) + newCol := len(prevLine) + buf.SetLine(line-1, prevLine+l) + buf.DeleteLine(line) + win.SetCursorLine(line - 1) + win.SetCursorCol(newCol) + } + + case "delete": + if col == len(l) && line < buf.LineCount()-1 { + nextLine := buf.Line(line + 1) + buf.SetLine(line, l+nextLine) + buf.DeleteLine(line + 1) + } else if col < len(l) { + buf.SetLine(line, l[:col]+l[col+1:]) + } + + case "tab": + tabs := strings.Repeat(" ", m.Settings().TabSize) + if col < len(l) { + buf.SetLine(line, l[:col]+tabs+l[col:]) + } else { + buf.SetLine(line, l+tabs) + } + win.SetCursorCol(col + len(tabs)) + + case "up": + if line > 0 { + win.SetCursorLine(line - 1) + win.ClampCursorX() + } + + case "down": + if line+1 < buf.LineCount() { + win.SetCursorLine(line + 1) + win.ClampCursorX() + } + + case "left": + if col > 0 { + win.SetCursorCol(col - 1) + } else if line > 0 { + prevLine := buf.Line(line - 1) + win.SetCursorCol(len(prevLine)) + win.SetCursorLine(line - 1) + } + + case "right": + if col < len(l) { + win.SetCursorCol(col + 1) + } else if line+1 < buf.LineCount() { + win.SetCursorCol(0) + win.SetCursorLine(line + 1) + } + + default: + if col < len(l) { + buf.SetLine(line, l[:col]+key+l[col:]) + } else { + buf.SetLine(line, l+key) + } + win.SetCursorCol(col + len(key)) + } +} + +// ================================================== +// Command Mode State +// ================================================== func (m *Model) Command() string { return m.command } @@ -202,7 +299,17 @@ func (m *Model) SetCommandOutput(out string) { m.commandOutput = out } -// Settings +// ================================================== +// Editor-wide State +// ================================================== +func (m *Model) Mode() action.Mode { + return m.mode +} + +func (m *Model) SetMode(mode action.Mode) { + m.mode = mode +} + func (m *Model) Settings() action.Settings { return m.settings } @@ -211,7 +318,9 @@ func (m *Model) SetSettings(s action.Settings) { m.settings = s } +// ================================================== // Registers +// ================================================== func (m *Model) Registers() map[rune]action.Register { return m.registers } @@ -244,224 +353,145 @@ func (m *Model) UpdateDefaultRegister(t action.RegisterType, cnt []string) { m.SetRegister('"', t, cnt) } -// Window -func (m *Model) ScrollY() int { - win := m.ActiveWindow() - return win.ScrollY -} - -func (m *Model) SetScrollY(y int) { - win := m.ActiveWindow() - win.ScrollY = y -} - -func (m *Model) WinH() int { - win := m.ActiveWindow() - return win.Height -} - -func (m *Model) WinW() int { - win := m.ActiveWindow() - return win.Width -} - -func (m *Model) ViewPortH() int { - win := m.ActiveWindow() - return win.Height - 2 -} - -func (m *Model) ClampCursorX() { - win := m.ActiveWindow() - win.ClampCursorX() -} - -// AdjustScroll ensures the cursor stays within the viewport with scrollOff margins. -// Call this after any cursor movement. -func (m *Model) AdjustScroll() { - viewportHeight := m.ViewPortH() - if viewportHeight <= 0 { - return - } - - // Effective scrollOff (can't be more than half the viewport) - off := min(m.Settings().ScrollOff, viewportHeight/2) - - // Cursor too close to top — scroll up - if m.CursorY() < m.ScrollY()+off { - m.SetScrollY(m.CursorY() - off) - } - - // Cursor too close to bottom — scroll down - if m.CursorY() > m.ScrollY()+viewportHeight-1-off { - m.SetScrollY(m.CursorY() - viewportHeight + 1 + off) - } - - // Clamp scrollY to valid range - maxScroll := max(0, m.LineCount()-viewportHeight) - m.SetScrollY(max(0, min(m.ScrollY(), maxScroll))) -} - -// Windows -func (m *Model) Windows() []*action.Window { - return m.windows -} - -func (m *Model) ActiveWindowId() int { - return m.activeWindowId -} - -func (m *Model) ActiveWindow() *action.Window { - winId := m.ActiveWindowId() - for i := range m.Windows() { - if m.windows[i].Id == winId { - return m.windows[i] - } - } - panic("Could not find window") -} - -func (m *Model) Mode() action.Mode { - return m.mode -} - -func (m *Model) SetMode(mode action.Mode) { - m.mode = mode -} - -func (m *Model) SetInsertRecording(count int, act action.Action) { - m.insertCount = count - m.insertKeys = []string{} - m.insertAction = act -} - -func (m *Model) GetCursorPosition() *action.Position { - // Return a copy of the position - win := m.ActiveWindow() - pos := win.Cursor - return &pos -} - -func (m *Model) replayInsert() { - win := m.ActiveWindow() - - // Replay (count - 1) more times - for i := 1; i < m.insertCount; i++ { - // For 'o' and 'O', we need to create a new line first - switch m.insertAction.(type) { - case action.OpenLineBelow: - pos := win.Cursor.Line - win.Buffer.Lines = append(win.Buffer.Lines[:pos+1], append([]string{""}, win.Buffer.Lines[pos+1:]...)...) - win.Cursor.Line++ - win.Cursor.Col = 0 - case action.OpenLineAbove: - pos := win.Cursor.Line - win.Buffer.Lines = append(win.Buffer.Lines[:pos], append([]string{""}, win.Buffer.Lines[pos:]...)...) - win.Cursor.Col = 0 - // 'i' and 'a' don't need setup - just replay keys - } - - // Replay each recorded keystroke - for _, key := range m.insertKeys { - m.processInsertKey(key) - } - } -} - -func (m *Model) ExitInsertMode() { - win := m.ActiveWindow() - if m.insertCount > 1 { - m.replayInsert() - } - if win.Cursor.Col > 0 { - win.Cursor.Col-- - } - m.mode = action.NormalMode - m.insertCount = 0 - m.insertKeys = nil -} - -func (m *Model) processInsertKey(key string) { - x := m.CursorX() - y := m.CursorY() - l := m.Line(y) - - switch key { - case "enter": - if x == len(l) { - m.InsertLine(y+1, "") - } else { - m.SetLine(y, l[:x]) - m.InsertLine(y+1, l[x:]) - } - m.SetCursorY(y + 1) - m.SetCursorX(0) - - case "backspace": - if x > 0 { - m.SetLine(y, l[:x-1]+l[x:]) - m.SetCursorX(x - 1) - } else if y > 0 { - prevLine := m.Line(y - 1) - newX := len(prevLine) - m.SetLine(y-1, prevLine+l) - m.DeleteLine(y) - m.SetCursorY(y - 1) - m.SetCursorX(newX) - } - - case "delete": - if x == len(l) && y < m.LineCount()-1 { - nextLine := m.Line(y + 1) - m.SetLine(y, l+nextLine) - m.DeleteLine(y + 1) - } else if x < len(l) { - m.SetLine(y, l[:x]+l[x+1:]) - } - - case "tab": - tabs := strings.Repeat(" ", m.Settings().TabSize) - if x < len(l) { - m.SetLine(y, l[:x]+tabs+l[x:]) - } else { - m.SetLine(y, l+tabs) - } - m.SetCursorX(x + len(tabs)) - - case "up": - if y > 0 { - m.SetCursorY(y - 1) - m.ClampCursorX() - } - - case "down": - if y+1 < m.LineCount() { - m.SetCursorY(y + 1) - m.ClampCursorX() - } - - case "left": - if x > 0 { - m.SetCursorX(x - 1) - } else if y > 0 { - prevLine := m.Line(y - 1) - m.SetCursorX(len(prevLine)) - m.SetCursorY(y - 1) - } - - case "right": - if x < len(l) { - m.SetCursorX(x + 1) - } else if y+1 < m.LineCount() { - m.SetCursorX(0) - m.SetCursorY(y + 1) - } - - default: - if x < len(l) { - m.SetLine(y, l[:x]+key+l[x:]) - } else { - m.SetLine(y, l+key) - } - m.SetCursorX(x + len(key)) - } -} +// ================================================== +// Depreciated +// ================================================== +// func (m *Model) Lines() []string { +// win := m.ActiveWindow() +// return win.Buffer.Lines +// } +// +// func (m *Model) Line(idx int) string { +// win := m.ActiveWindow() +// return win.Buffer.Line(idx) +// } +// +// func (m *Model) SetLine(idx int, content string) { +// win := m.ActiveWindow() +// win.Buffer.SetLine(idx, content) +// } +// +// func (m *Model) InsertLine(idx int, content string) { +// win := m.ActiveWindow() +// win.Buffer.InsertLine(idx, content) +// } +// +// func (m *Model) DeleteLine(idx int) { +// win := m.ActiveWindow() +// win.Buffer.DeleteLine(idx) +// } +// +// func (m *Model) LineCount() int { +// win := m.ActiveWindow() +// return win.Buffer.LineCount() +// } +// +// func (m *Model) CursorX() int { +// win := m.ActiveWindow() +// return win.Cursor.Col +// } +// +// func (m *Model) CursorY() int { +// win := m.ActiveWindow() +// return win.Cursor.Line +// } +// +// func (m *Model) SetCursorX(x int) { +// win := m.ActiveWindow() +// win.Cursor.Col = x +// } +// +// func (m *Model) SetCursorY(y int) { +// win := m.ActiveWindow() +// win.Cursor.Line = y +// } +// +// // Anchor methods +// func (m *Model) AnchorX() int { +// win := m.ActiveWindow() +// return win.Anchor.Col +// } +// +// func (m *Model) AnchorY() int { +// win := m.ActiveWindow() +// return win.Anchor.Line +// } +// +// func (m *Model) SetAnchorX(x int) { +// win := m.ActiveWindow() +// win.Anchor.Col = x +// } +// +// func (m *Model) SetAnchorY(y int) { +// win := m.ActiveWindow() +// win.Anchor.Line = y +// } +// +// func (m *Model) GetCursorPosition() *action.Position { +// // Return a copy of the position +// win := m.ActiveWindow() +// pos := win.Cursor +// return &pos +// } +// +// // Window +// func (m *Model) ScrollY() int { +// win := m.ActiveWindow() +// return win.ScrollY +// } +// +// func (m *Model) SetScrollY(y int) { +// win := m.ActiveWindow() +// win.ScrollY = y +// } +// +// func (m *Model) WinH() int { +// win := m.ActiveWindow() +// return win.Height +// } +// +// func (m *Model) WinW() int { +// win := m.ActiveWindow() +// return win.Width +// } +// +// func (m *Model) ViewPortH() int { +// win := m.ActiveWindow() +// return win.Height - 2 +// } +// +// func (m *Model) ClampCursorX() { +// win := m.ActiveWindow() +// win.ClampCursorX() +// } +// +// func (m *Model) ActiveWindowId() int { +// return m.activeWindowId +// } +// +// // TODO: MOVE THIS +// // AdjustScroll ensures the cursor stays within the viewport with scrollOff margins. +// // Call this after any cursor movement. +// func (m *Model) AdjustScroll() { +// viewportHeight := m.ViewPortH() +// if viewportHeight <= 0 { +// return +// } +// +// // Effective scrollOff (can't be more than half the viewport) +// off := min(m.Settings().ScrollOff, viewportHeight/2) +// +// // Cursor too close to top — scroll up +// if m.CursorY() < m.ScrollY()+off { +// m.SetScrollY(m.CursorY() - off) +// } +// +// // Cursor too close to bottom — scroll down +// if m.CursorY() > m.ScrollY()+viewportHeight-1-off { +// m.SetScrollY(m.CursorY() - viewportHeight + 1 + off) +// } +// +// // Clamp scrollY to valid range +// maxScroll := max(0, m.LineCount()-viewportHeight) +// m.SetScrollY(max(0, min(m.ScrollY(), maxScroll))) +// }