From f0f3f95e7b56f0671efd872fc6d25460b7d368e2 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Wed, 11 Feb 2026 15:00:02 -0700 Subject: [PATCH] feat: highlighting implemented! No tests yet --- internal/action/action.go | 9 +++++ internal/action/misc.go | 30 +++++++++++++++ internal/editor/model.go | 18 +++++++++ internal/editor/style.go | 19 +++++++++- internal/editor/update.go | 9 ++++- internal/editor/view.go | 77 ++++++++++++++++++++++++++++++++++++++- internal/input/handler.go | 33 ++++++++++++++--- internal/input/keymap.go | 38 +++++++++++++++++++ 8 files changed, 222 insertions(+), 11 deletions(-) diff --git a/internal/action/action.go b/internal/action/action.go index f9207a7..758eb15 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -9,6 +9,9 @@ const ( NormalMode Mode = iota InsertMode CommandMode + VisualMode + VisualLineMode + VisualBlockMode ) // Model defines the interface for editor state that actions can modify @@ -28,6 +31,12 @@ type Model interface { SetCursorY(y int) ClampCursorX() + // Anchor + AnchorX() int + AnchorY() int + SetAnchorX(x int) + SetAnchorY(y int) + // Mode Mode() Mode SetMode(mode Mode) diff --git a/internal/action/misc.go b/internal/action/misc.go index 2b53ae2..76cbbed 100644 --- a/internal/action/misc.go +++ b/internal/action/misc.go @@ -16,3 +16,33 @@ func (a EnterComandMode) Execute(m Model) tea.Cmd { m.SetMode(CommandMode) return nil } + +// Quit implements Action (v) +type EnterVisualMode struct{} + +func (a EnterVisualMode) Execute(m Model) tea.Cmd { + m.SetAnchorX(m.CursorX()) + m.SetAnchorY(m.CursorY()) + m.SetMode(VisualMode) + return nil +} + +// Quit implements Action (V) +type EnterVisualLineMode struct{} + +func (a EnterVisualLineMode) Execute(m Model) tea.Cmd { + m.SetAnchorX(m.CursorX()) + m.SetAnchorY(m.CursorY()) + m.SetMode(VisualLineMode) + return nil +} + +// Quit implements Action (ctrl+v) +type EnterVisualBlockMode struct{} + +func (a EnterVisualBlockMode) Execute(m Model) tea.Cmd { + m.SetAnchorX(m.CursorX()) + m.SetAnchorY(m.CursorY()) + m.SetMode(VisualBlockMode) + return nil +} diff --git a/internal/editor/model.go b/internal/editor/model.go index 63be138..bf82272 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -16,6 +16,7 @@ type cursor struct { type Model struct { lines []string cursor cursor + anchor cursor // starting point for visual modes mode action.Mode win_h int win_w int @@ -106,6 +107,23 @@ func (m *Model) SetCursorY(y int) { m.cursor.y = y } +// Anchor methods +func (m *Model) AnchorX() int { + return m.anchor.x +} + +func (m *Model) AnchorY() int { + return m.anchor.y +} + +func (m *Model) SetAnchorX(x int) { + m.anchor.x = x +} + +func (m *Model) SetAnchorY(y int) { + m.anchor.y = y +} + func (m *Model) ClampCursorX() { lineLen := len(m.lines[m.cursor.y]) if lineLen == 0 { diff --git a/internal/editor/style.go b/internal/editor/style.go index 5df1932..749a9ca 100644 --- a/internal/editor/style.go +++ b/internal/editor/style.go @@ -7,7 +7,10 @@ import ( func (m Model) cursorStyle() lipgloss.Style { switch m.mode { - case action.NormalMode: + case action.NormalMode, + action.VisualMode, + action.VisualBlockMode, + action.VisualLineMode: // Block cursor for normal mode return lipgloss.NewStyle().Reverse(true) case action.InsertMode: @@ -20,13 +23,25 @@ func (m Model) cursorStyle() lipgloss.Style { } } +// DEBUGGING STYLE +func (m Model) visualAnchorStyle() lipgloss.Style { + bg := lipgloss.Color("#a89020") + return lipgloss.NewStyle().Background(bg) +} + func (m Model) gutterStyle(currentLine bool) lipgloss.Style { + bg := lipgloss.Color("236") fg := lipgloss.Color("243") if currentLine { fg = lipgloss.Color("#d69d00") } return lipgloss.NewStyle(). Width(m.gutterSize). - Background(lipgloss.Color("236")). + Background(bg). Foreground(fg) } + +func (m Model) visualHighlightStyle() lipgloss.Style { + bg := lipgloss.Color("#7a6a00") + return lipgloss.NewStyle().Background(bg) +} diff --git a/internal/editor/update.go b/internal/editor/update.go index e4d620f..f64fde3 100644 --- a/internal/editor/update.go +++ b/internal/editor/update.go @@ -13,8 +13,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.win_w = msg.Width case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + switch m.mode { - case action.NormalMode: + case action.NormalMode, + action.VisualMode, + action.VisualBlockMode, + action.VisualLineMode: return m, m.input.Handle(&m, msg.String()) // TODO: This should be handled elsewhere diff --git a/internal/editor/view.go b/internal/editor/view.go index f949f25..a4ba3f7 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -7,6 +7,61 @@ import ( "git.gophernest.net/azpect/TextEditor/internal/action" ) +func posInsideSelection(m Model, col, line int) bool { + switch m.Mode() { + case action.VisualLineMode: + startY := min(m.AnchorY(), m.CursorY()) + endY := max(m.AnchorY(), m.CursorY()) + return line >= startY && line <= endY + + case action.VisualMode: + ax := m.AnchorX() + ay := m.AnchorY() + + cx := m.CursorX() + cy := m.CursorY() + + // Normalize so start is always before end in document order + var startX, startY, endX, endY int + if ay < cy || (ay == cy && ax <= cx) { + startX, startY = ax, ay + endX, endY = cx, cy + } else { + startX, startY = cx, cy + endX, endY = ax, ay + } + + // Position is inside if it falls within [start, end] inclusive + afterStart := line > startY || (line == startY && col >= startX) + beforeEnd := line < endY || (line == endY && col <= endX) + return afterStart && beforeEnd + + case action.VisualBlockMode: + startX := min(m.AnchorX(), m.CursorX()) + startY := min(m.AnchorY(), m.CursorY()) + endX := max(m.AnchorX(), m.CursorX()) + endY := max(m.AnchorY(), m.CursorY()) + + return col >= startX && col <= endX && + line >= startY && line <= endY + + default: + return false + } +} + +func posIsAnchor(m Model, col, line int) bool { + ax := m.AnchorX() + ay := m.AnchorY() + return col == ax && line == ay +} + +func isVisualMode(m action.Mode) bool { + return m == action.VisualMode || + m == action.VisualLineMode || + m == action.VisualBlockMode +} + func (m Model) View() string { var view strings.Builder @@ -45,7 +100,16 @@ func (m Model) View() string { view.WriteString(m.cursorStyle().Render(" ")) } } else if x < len(runes) { - view.WriteRune(runes[x]) + if isVisualMode(m.Mode()) && posIsAnchor(m, x, y) { + view.WriteString(m.visualAnchorStyle().Render(string(runes[x]))) + } else if isVisualMode(m.Mode()) && posInsideSelection(m, x, y) { + view.WriteString(m.visualHighlightStyle().Render(string(runes[x]))) + } else { + view.WriteRune(runes[x]) + } + // To highlight blank lines when in visual mode + } else if isVisualMode(m.Mode()) && posInsideSelection(m, x, y) { + view.WriteString(m.visualHighlightStyle().Render(" ")) } } } else { @@ -65,11 +129,20 @@ func (m Model) View() string { modeString = "INSERT" case action.CommandMode: modeString = "COMMAND" + case action.VisualMode: + modeString = "VISUAL" + case action.VisualLineMode: + modeString = "V-LINE" + case action.VisualBlockMode: + modeString = "V-BLOCK" } + // DEBUG BAR! Def not the final bar var bar string - if m.mode == action.CommandMode { + if m.Mode() == action.CommandMode { bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) :%s ", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.command) + } else if isVisualMode(m.Mode()) { + bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) <%d, %d> ", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.AnchorX(), m.AnchorY()) } else { bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) | %s | %+v | %d", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.input.Pending(), m.insertKeys, m.insertCount) } diff --git a/internal/input/handler.go b/internal/input/handler.go index 3c2d90e..d71db6e 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -1,8 +1,8 @@ package input import ( - tea "github.com/charmbracelet/bubbletea" "git.gophernest.net/azpect/TextEditor/internal/action" + tea "github.com/charmbracelet/bubbletea" ) type InputState int @@ -27,19 +27,29 @@ type Handler struct { operatorKey string // track which key started operator (for dd, yy, cc) buffer string // for display (what user has typed) pending string // partial key sequence (e.g., "g" waiting for second key) - keymap *Keymap + + // Keymaps + normalKeymap *Keymap + visualKeymap *Keymap + + currentKeymap *Keymap } func NewHandler() *Handler { return &Handler{ - keymap: NewNormalKeymap(), + // keymap: NewNormalKeymap(), + normalKeymap: NewNormalKeymap(), + visualKeymap: NewVisualKeymap(), + currentKeymap: nil, } } func (h *Handler) Handle(m action.Model, key string) tea.Cmd { // ESC always resets everything + // TODO: This should prob be relocated if key == "esc" { h.Reset() + m.SetMode(action.NormalMode) return nil } @@ -51,8 +61,19 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd { // Build the sequence (pending + new key) sequence := h.pending + key + // Set working keymap + // TODO: Do we need to reset anywhere? + switch m.Mode() { + case action.NormalMode: + h.currentKeymap = h.normalKeymap + case action.VisualMode, + action.VisualLineMode, + action.VisualBlockMode: + h.currentKeymap = h.visualKeymap + } + // Check for exact match with full sequence - kind, binding := h.keymap.Lookup(sequence) + kind, binding := h.currentKeymap.Lookup(sequence) if kind != "" { h.pending = "" h.buffer += key @@ -60,7 +81,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd { } // No exact match - could this be a prefix of something? - if h.keymap.HasPrefix(sequence) { + if h.currentKeymap.HasPrefix(sequence) { h.pending = sequence h.buffer += key return nil // wait for more keys @@ -69,7 +90,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd { // Not a prefix either - if we had pending, try just the new key if h.pending != "" { h.pending = "" - kind, binding = h.keymap.Lookup(key) + kind, binding = h.currentKeymap.Lookup(key) if kind != "" { h.buffer = key return h.dispatch(m, kind, binding, key) diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 0279bde..5835a74 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -31,6 +31,9 @@ func NewNormalKeymap() *Keymap { // "d": DeleteOp{}, // "c": ChangeOp{}, // "y": YankOp{}, + // "p": PasteOp{}, + // "s": SubstitueOp{}, + // "~": SwapCaseOp{}, }, actions: map[string]action.Action{ "i": action.EnterInsert{}, @@ -42,6 +45,41 @@ func NewNormalKeymap() *Keymap { "x": action.DeleteChar{Count: 1}, "ctrl+c": action.Quit{}, ":": action.EnterComandMode{}, + "v": action.EnterVisualMode{}, + "V": action.EnterVisualLineMode{}, + "ctrl+v": action.EnterVisualBlockMode{}, + }, + } +} + +func NewVisualKeymap() *Keymap { + return &Keymap{ + motions: map[string]action.Motion{ + "j": motion.MoveDown{Count: 1}, + "k": motion.MoveUp{Count: 1}, + "h": motion.MoveLeft{Count: 1}, + "l": motion.MoveRight{Count: 1}, + "G": motion.MoveToBottom{}, + "gg": motion.MoveToTop{}, + "0": motion.MoveToLineStart{}, + "$": motion.MoveToLineEnd{}, + "_": motion.MoveToLineContentStart{}, + "w": motion.MoveForwardWord{Count: 1}, + "e": motion.MoveForwardWordEnd{Count: 1}, + "b": motion.MoveBackwardWord{Count: 1}, + }, + operators: map[string]action.Operator{ + // "d": DeleteOp{}, + // "c": ChangeOp{}, + // "y": YankOp{}, + // "p": PasteOp{}, + // "s": SubstitueOp{}, + // "x": DeleteOp{}, + // "~": SwapCaseOp{}, + }, + actions: map[string]action.Action{ + "ctrl+c": action.Quit{}, + // ":": action.EnterComandMode{}, // Different OP }, } }