feat: highlighting implemented! No tests yet

This commit is contained in:
Hayden Hargreaves 2026-02-11 15:00:02 -07:00
parent f2496f91dd
commit f0f3f95e7b
8 changed files with 222 additions and 11 deletions

View File

@ -9,6 +9,9 @@ const (
NormalMode Mode = iota NormalMode Mode = iota
InsertMode InsertMode
CommandMode CommandMode
VisualMode
VisualLineMode
VisualBlockMode
) )
// Model defines the interface for editor state that actions can modify // Model defines the interface for editor state that actions can modify
@ -28,6 +31,12 @@ type Model interface {
SetCursorY(y int) SetCursorY(y int)
ClampCursorX() ClampCursorX()
// Anchor
AnchorX() int
AnchorY() int
SetAnchorX(x int)
SetAnchorY(y int)
// Mode // Mode
Mode() Mode Mode() Mode
SetMode(mode Mode) SetMode(mode Mode)

View File

@ -16,3 +16,33 @@ func (a EnterComandMode) Execute(m Model) tea.Cmd {
m.SetMode(CommandMode) m.SetMode(CommandMode)
return nil 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
}

View File

@ -16,6 +16,7 @@ type cursor struct {
type Model struct { type Model struct {
lines []string lines []string
cursor cursor cursor cursor
anchor cursor // starting point for visual modes
mode action.Mode mode action.Mode
win_h int win_h int
win_w int win_w int
@ -106,6 +107,23 @@ func (m *Model) SetCursorY(y int) {
m.cursor.y = y 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() { func (m *Model) ClampCursorX() {
lineLen := len(m.lines[m.cursor.y]) lineLen := len(m.lines[m.cursor.y])
if lineLen == 0 { if lineLen == 0 {

View File

@ -7,7 +7,10 @@ import (
func (m Model) cursorStyle() lipgloss.Style { func (m Model) cursorStyle() lipgloss.Style {
switch m.mode { switch m.mode {
case action.NormalMode: case action.NormalMode,
action.VisualMode,
action.VisualBlockMode,
action.VisualLineMode:
// Block cursor for normal mode // Block cursor for normal mode
return lipgloss.NewStyle().Reverse(true) return lipgloss.NewStyle().Reverse(true)
case action.InsertMode: 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 { func (m Model) gutterStyle(currentLine bool) lipgloss.Style {
bg := lipgloss.Color("236")
fg := lipgloss.Color("243") fg := lipgloss.Color("243")
if currentLine { if currentLine {
fg = lipgloss.Color("#d69d00") fg = lipgloss.Color("#d69d00")
} }
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Width(m.gutterSize). Width(m.gutterSize).
Background(lipgloss.Color("236")). Background(bg).
Foreground(fg) Foreground(fg)
} }
func (m Model) visualHighlightStyle() lipgloss.Style {
bg := lipgloss.Color("#7a6a00")
return lipgloss.NewStyle().Background(bg)
}

View File

@ -13,8 +13,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.win_w = msg.Width m.win_w = msg.Width
case tea.KeyMsg: case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
switch m.mode { switch m.mode {
case action.NormalMode: case action.NormalMode,
action.VisualMode,
action.VisualBlockMode,
action.VisualLineMode:
return m, m.input.Handle(&m, msg.String()) return m, m.input.Handle(&m, msg.String())
// TODO: This should be handled elsewhere // TODO: This should be handled elsewhere

View File

@ -7,6 +7,61 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/action" "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 { func (m Model) View() string {
var view strings.Builder var view strings.Builder
@ -45,7 +100,16 @@ func (m Model) View() string {
view.WriteString(m.cursorStyle().Render(" ")) view.WriteString(m.cursorStyle().Render(" "))
} }
} else if x < len(runes) { } 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 { } else {
@ -65,11 +129,20 @@ func (m Model) View() string {
modeString = "INSERT" modeString = "INSERT"
case action.CommandMode: case action.CommandMode:
modeString = "COMMAND" 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 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) 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 { } 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) 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)
} }

View File

@ -1,8 +1,8 @@
package input package input
import ( import (
tea "github.com/charmbracelet/bubbletea"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
tea "github.com/charmbracelet/bubbletea"
) )
type InputState int type InputState int
@ -27,19 +27,29 @@ type Handler struct {
operatorKey string // track which key started operator (for dd, yy, cc) operatorKey string // track which key started operator (for dd, yy, cc)
buffer string // for display (what user has typed) buffer string // for display (what user has typed)
pending string // partial key sequence (e.g., "g" waiting for second key) pending string // partial key sequence (e.g., "g" waiting for second key)
keymap *Keymap
// Keymaps
normalKeymap *Keymap
visualKeymap *Keymap
currentKeymap *Keymap
} }
func NewHandler() *Handler { func NewHandler() *Handler {
return &Handler{ return &Handler{
keymap: NewNormalKeymap(), // keymap: NewNormalKeymap(),
normalKeymap: NewNormalKeymap(),
visualKeymap: NewVisualKeymap(),
currentKeymap: nil,
} }
} }
func (h *Handler) Handle(m action.Model, key string) tea.Cmd { func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
// ESC always resets everything // ESC always resets everything
// TODO: This should prob be relocated
if key == "esc" { if key == "esc" {
h.Reset() h.Reset()
m.SetMode(action.NormalMode)
return nil return nil
} }
@ -51,8 +61,19 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
// Build the sequence (pending + new key) // Build the sequence (pending + new key)
sequence := h.pending + 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 // Check for exact match with full sequence
kind, binding := h.keymap.Lookup(sequence) kind, binding := h.currentKeymap.Lookup(sequence)
if kind != "" { if kind != "" {
h.pending = "" h.pending = ""
h.buffer += key 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? // No exact match - could this be a prefix of something?
if h.keymap.HasPrefix(sequence) { if h.currentKeymap.HasPrefix(sequence) {
h.pending = sequence h.pending = sequence
h.buffer += key h.buffer += key
return nil // wait for more keys 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 // Not a prefix either - if we had pending, try just the new key
if h.pending != "" { if h.pending != "" {
h.pending = "" h.pending = ""
kind, binding = h.keymap.Lookup(key) kind, binding = h.currentKeymap.Lookup(key)
if kind != "" { if kind != "" {
h.buffer = key h.buffer = key
return h.dispatch(m, kind, binding, key) return h.dispatch(m, kind, binding, key)

View File

@ -31,6 +31,9 @@ func NewNormalKeymap() *Keymap {
// "d": DeleteOp{}, // "d": DeleteOp{},
// "c": ChangeOp{}, // "c": ChangeOp{},
// "y": YankOp{}, // "y": YankOp{},
// "p": PasteOp{},
// "s": SubstitueOp{},
// "~": SwapCaseOp{},
}, },
actions: map[string]action.Action{ actions: map[string]action.Action{
"i": action.EnterInsert{}, "i": action.EnterInsert{},
@ -42,6 +45,41 @@ func NewNormalKeymap() *Keymap {
"x": action.DeleteChar{Count: 1}, "x": action.DeleteChar{Count: 1},
"ctrl+c": action.Quit{}, "ctrl+c": action.Quit{},
":": action.EnterComandMode{}, ":": 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
}, },
} }
} }