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
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)

View File

@ -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
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}

View File

@ -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)

View File

@ -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
},
}
}