Compare commits

..

No commits in common. "93968e7333adf525dbd6952f1fc639806b4e3dc0" and "ccb061989a80b3f16ba8a5f89e5ad61b90562498" have entirely different histories.

11 changed files with 434 additions and 471 deletions

View File

@ -16,7 +16,6 @@ func main() {
win := core.NewWindowBuilder().
WithBuffer(&buf).
WithOptions(core.NewDefaultWinOptions()).
Build()
model := editor.NewModelBuilder().

View File

@ -241,7 +241,7 @@ func (a InsertTab) Execute(m Model) tea.Cmd {
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
tabs := strings.Repeat(" ", m.Settings().TabStop)
tabs := strings.Repeat(" ", m.Settings().TabSize)
if x < len(l) {
buf.SetLine(y, l[:x]+tabs+l[x:])
} else {

View File

@ -45,8 +45,8 @@ type Model interface {
Mode() core.Mode
SetMode(mode core.Mode)
Settings() core.EditorSettings
SetSettings(s core.EditorSettings)
Settings() core.Settings
SetSettings(s core.Settings)
// ==================================================
// Registers
@ -55,6 +55,7 @@ type Model interface {
GetRegister(name rune) (core.Register, bool)
SetRegister(name rune, t core.RegisterType, cnt []string) error
UpdateDefaultRegister(t core.RegisterType, cnt []string)
}
// Action is the base interface - anything executable

View File

@ -110,15 +110,7 @@ type Setting struct {
Name string
ShortForm string
Type SettingType
Get func(s core.EditorSettings) any
Set func(m action.Model, val any)
}
type WindowSetting struct {
Name string
ShortForm string
Type SettingType
Get func(m action.Model) any
Get func(s core.Settings) any
Set func(m action.Model, val any)
}
@ -131,69 +123,50 @@ const (
StringSetting
)
// settingsMap defines all available editor settings
// settingsMap defines all available settings
var settingsMap = []Setting{
{
Name: "tabstop",
ShortForm: "ts",
Type: IntSetting,
Get: func(s core.EditorSettings) any { return s.TabStop },
Set: func(m action.Model, val any) {
s := m.Settings()
s.TabStop = val.(int)
m.SetSettings(s)
},
},
}
// windowSettingsMap defines all available window settings
var windowSettingsMap = []WindowSetting{
{
Name: "number",
ShortForm: "nu",
Type: BoolSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.Number },
Get: func(s core.Settings) any { return s.Number },
Set: func(m action.Model, val any) {
w := m.ActiveWindow()
o := w.Options
o.Number = val.(bool)
w.SetOptions(o)
s := m.Settings()
s.Number = val.(bool)
m.SetSettings(s)
},
},
{
Name: "relativenumber",
ShortForm: "rnu",
Type: BoolSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.RelativeNumber },
Get: func(s core.Settings) any { return s.RelativeNumber },
Set: func(m action.Model, val any) {
w := m.ActiveWindow()
o := w.Options
o.RelativeNumber = val.(bool)
w.SetOptions(o)
s := m.Settings()
s.RelativeNumber = val.(bool)
m.SetSettings(s)
},
},
{
Name: "tabstop",
ShortForm: "ts",
Type: IntSetting,
Get: func(s core.Settings) any { return s.TabSize },
Set: func(m action.Model, val any) {
s := m.Settings()
s.TabSize = val.(int)
m.SetSettings(s)
},
},
{
Name: "scrolloff",
ShortForm: "so",
Type: IntSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.ScrollOff },
Get: func(s core.Settings) any { return s.ScrollOff },
Set: func(m action.Model, val any) {
w := m.ActiveWindow()
o := w.Options
o.ScrollOff = val.(int)
w.SetOptions(o)
},
},
{
Name: "guttersize",
ShortForm: "gu",
Type: IntSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.GutterSize },
Set: func(m action.Model, val any) {
w := m.ActiveWindow()
o := w.Options
o.GutterSize = val.(int)
w.SetOptions(o)
s := m.Settings()
s.ScrollOff = val.(int)
m.SetSettings(s)
},
},
}
@ -213,27 +186,14 @@ func lookupSetting(name string) *Setting {
return nil
}
// lookupWindowSetting: Finds a window setting by name, short form, or prefix.
func lookupWindowSetting(name string) *WindowSetting {
for i := range windowSettingsMap {
s := &windowSettingsMap[i]
if name == s.Name || name == s.ShortForm {
return s
}
// Prefix matching
if len(name) >= len(s.ShortForm) && strings.HasPrefix(s.Name, name) {
return s
}
}
return nil
}
// parseSetOption: Parses and applies a single :set option.
func parseSetOption(m action.Model, opt string) error {
// Handle toggle: option!
if name, ok := strings.CutSuffix(opt, "!"); ok {
setting := lookupSetting(name)
if setting != nil {
if setting == nil {
return nil // Unknown setting
}
if setting.Type == BoolSetting {
// Toggle the boolean
currentVal := setting.Get(m.Settings()).(bool)
@ -242,47 +202,26 @@ func parseSetOption(m action.Model, opt string) error {
return nil
}
windowSetting := lookupWindowSetting(name)
if windowSetting != nil {
if windowSetting.Type == BoolSetting {
// Toggle the boolean
currentVal := windowSetting.Get(m).(bool)
windowSetting.Set(m, !currentVal)
}
return nil
}
return nil
}
// Handle disable: nooption
if name, ok := strings.CutPrefix(opt, "no"); ok {
setting := lookupSetting(name)
if setting != nil {
if setting == nil {
return nil
}
if setting.Type == BoolSetting {
setting.Set(m, false)
}
return nil
}
windowSetting := lookupWindowSetting(name)
if windowSetting != nil {
if windowSetting.Type == BoolSetting {
windowSetting.Set(m, false)
}
return nil
}
return nil
}
// Handle assignment: option=value
if strings.Contains(opt, "=") {
parts := strings.SplitN(opt, "=", 2)
name, value := parts[0], parts[1]
setting := lookupSetting(name)
if setting != nil {
if setting == nil {
return nil
}
switch setting.Type {
case IntSetting:
intVal, err := strconv.Atoi(value)
@ -300,44 +239,14 @@ func parseSetOption(m action.Model, opt string) error {
return nil
}
windowSetting := lookupWindowSetting(name)
if windowSetting != nil {
switch windowSetting.Type {
case IntSetting:
intVal, err := strconv.Atoi(value)
if err != nil {
return err
}
windowSetting.Set(m, intVal)
case StringSetting:
windowSetting.Set(m, value)
case BoolSetting:
// Handle :set option=true / :set option=false
boolVal := value == "true" || value == "1" || value == "yes"
windowSetting.Set(m, boolVal)
}
return nil
}
return nil
}
// Handle enable: option (boolean only)
setting := lookupSetting(opt)
if setting != nil {
if setting == nil {
return nil
}
if setting.Type == BoolSetting {
setting.Set(m, true)
}
return nil
}
windowSetting := lookupWindowSetting(opt)
if windowSetting != nil {
if windowSetting.Type == BoolSetting {
windowSetting.Set(m, true)
}
return nil
}
return nil
}

View File

@ -1,15 +1,23 @@
package core
// EditorSettings: Configuration options for editor display and behavior.
type EditorSettings struct {
TabStop int
// Settings: Configuration options for editor display and behavior.
type Settings struct {
Number bool
RelativeNumber bool
GutterSize int
TabSize int
ScrollOff int
// TODO: Colors
}
// NewDefaultSettings: Creates a Settings struct with sensible defaults for
// line numbers, gutter width, tab size, and scroll offset.
func NewDefaultSettings() EditorSettings {
return EditorSettings{
TabStop: 2,
func NewDefaultSettings() Settings {
return Settings{
Number: true,
RelativeNumber: true,
GutterSize: 5,
TabSize: 2,
ScrollOff: 8,
}
}

View File

@ -2,22 +2,12 @@ package core
// TODO: No more global settings, window-wide settings
type WinOptions struct {
Number bool
RelativeNumber bool
GutterSize int
// Number bool
// Wrap bool
// Relnumber bool
ScrollOff int
}
func NewDefaultWinOptions() WinOptions {
return WinOptions{
Number: true,
RelativeNumber: true,
GutterSize: 5,
ScrollOff: 8,
}
}
type Window struct {
Id int
Number int // Ignored for now, will be used when splits come into play
@ -72,10 +62,8 @@ func (w *Window) AdjustScroll() {
return
}
viewPort := w.ViewportHeight()
// Effective scrollOff (can't be more than half the viewport)
off := min(w.Options.ScrollOff, viewPort/2)
off := min(w.Options.ScrollOff, w.Height/2)
// Cursor too close to top — scroll up
if w.Cursor.Line < w.ScrollY+off {
@ -83,27 +71,15 @@ func (w *Window) AdjustScroll() {
}
// Cursor too close to bottom — scroll down
if w.Cursor.Line > w.ScrollY+viewPort-1-off {
w.ScrollY = w.Cursor.Line - viewPort + 1 + off
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()-viewPort)
maxScroll := max(0, w.Buffer.LineCount()-w.Height)
w.ScrollY = max(0, min(w.ScrollY, maxScroll))
}
// ==================================================
// Getters (for computed values)
// ==================================================
func (w *Window) ViewportHeight() int {
// TODO: This will need more magic when splits come into play
// -1 for command bar
// -1 for status line
return w.Height - 2
}
// ==================================================
// Setters
// ==================================================
@ -191,8 +167,3 @@ func (w *Window) SetDimensions(width, height int) {
w.Width = width
w.Height = height
}
// Window.SetOptions: Sets the options of this window.
func (w *Window) SetOptions(opts WinOptions) {
w.Options = opts
}

View File

@ -20,7 +20,9 @@ func NewWindowBuilder() *WindowBuilder {
ScrollY: 0,
Height: 0,
Width: 0,
Options: NewDefaultWinOptions(),
Options: WinOptions{
ScrollOff: 8, // 8 is default
},
},
}
}

View File

@ -19,7 +19,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "o", "n", "u", "m", "b", "e", "r", "enter")
m := getFinalModel(t, tm)
if m.ActiveWindow().Options.Number {
if m.Settings().Number {
t.Error("expected Number to be false after :set nonumber")
}
})
@ -33,7 +33,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "enter")
m := getFinalModel(t, tm)
if !m.ActiveWindow().Options.Number {
if !m.Settings().Number {
t.Error("expected Number to be true after :set nu")
}
})
@ -46,7 +46,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "m", "b", "e", "r", "!", "enter")
m := getFinalModel(t, tm)
if m.ActiveWindow().Options.Number {
if m.Settings().Number {
t.Error("expected Number to be false after :set number!")
}
})
@ -60,7 +60,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "!", "enter")
m := getFinalModel(t, tm)
if !m.ActiveWindow().Options.Number {
if !m.Settings().Number {
t.Error("expected Number to be true after double toggle")
}
})
@ -73,7 +73,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "o", "r", "n", "u", "enter")
m := getFinalModel(t, tm)
if m.ActiveWindow().Options.RelativeNumber {
if m.Settings().RelativeNumber {
t.Error("expected RelativeNumber to be false after :set nornu")
}
})
@ -87,7 +87,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "r", "n", "u", "enter")
m := getFinalModel(t, tm)
if !m.ActiveWindow().Options.RelativeNumber {
if !m.Settings().RelativeNumber {
t.Error("expected RelativeNumber to be true after :set rnu")
}
})
@ -95,15 +95,15 @@ func TestCommandSetBoolean(t *testing.T) {
func TestCommandSetInteger(t *testing.T) {
t.Run("':set tabstop=4' sets tab size", func(t *testing.T) {
// Default TabStop=2
// Default TabSize=2
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, ":", "s", "e", "t", " ", "t", "a", "b", "s", "t", "o", "p", "=", "4", "enter")
m := getFinalModel(t, tm)
if m.Settings().TabStop != 4 {
t.Errorf("TabStop = %d, want 4", m.Settings().TabStop)
if m.Settings().TabSize != 4 {
t.Errorf("TabSize = %d, want 4", m.Settings().TabSize)
}
})
@ -114,8 +114,8 @@ func TestCommandSetInteger(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "t", "s", "=", "8", "enter")
m := getFinalModel(t, tm)
if m.Settings().TabStop != 8 {
t.Errorf("TabStop = %d, want 8", m.Settings().TabStop)
if m.Settings().TabSize != 8 {
t.Errorf("TabSize = %d, want 8", m.Settings().TabSize)
}
})
@ -127,8 +127,8 @@ func TestCommandSetInteger(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "s", "c", "r", "o", "l", "l", "o", "f", "f", "=", "5", "enter")
m := getFinalModel(t, tm)
if m.ActiveWindow().Options.ScrollOff != 5 {
t.Errorf("ScrollOff = %d, want 5", m.ActiveWindow().Options.ScrollOff)
if m.Settings().ScrollOff != 5 {
t.Errorf("ScrollOff = %d, want 5", m.Settings().ScrollOff)
}
})
@ -139,8 +139,8 @@ func TestCommandSetInteger(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "s", "o", "=", "1", "0", "enter")
m := getFinalModel(t, tm)
if m.ActiveWindow().Options.ScrollOff != 10 {
t.Errorf("ScrollOff = %d, want 10", m.ActiveWindow().Options.ScrollOff)
if m.Settings().ScrollOff != 10 {
t.Errorf("ScrollOff = %d, want 10", m.Settings().ScrollOff)
}
})
}

View File

@ -42,7 +42,7 @@ type Model struct {
commandOutput string
// Global settings (TODO: This needs to be refactored)
settings core.EditorSettings
settings core.Settings
// Registers
registers map[rune]core.Register // name -> register
@ -185,7 +185,7 @@ func (m *Model) processInsertKey(key string) {
}
case "tab":
tabs := strings.Repeat(" ", m.Settings().TabStop)
tabs := strings.Repeat(" ", m.Settings().TabSize)
if col < len(l) {
buf.SetLine(line, l[:col]+tabs+l[col:])
} else {
@ -282,11 +282,11 @@ func (m *Model) SetMode(mode core.Mode) {
m.mode = mode
}
func (m *Model) Settings() core.EditorSettings {
func (m *Model) Settings() core.Settings {
return m.settings
}
func (m *Model) SetSettings(s core.EditorSettings) {
func (m *Model) SetSettings(s core.Settings) {
m.settings = s
}
@ -334,3 +334,146 @@ func (m *Model) UpdateDefaultRegister(t core.RegisterType, cnt []string) {
m.SetRegister('0', t, cnt)
m.SetRegister('"', t, cnt)
}
// ==================================================
// 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)))
// }

View File

@ -93,7 +93,7 @@ func (mb *ModelBuilder) WithTermHeight(height int) *ModelBuilder {
}
// ModelBuilder.WithSettings: Set the editor settings (tabstop, scrolloff, etc).
func (mb *ModelBuilder) WithSettings(settings core.EditorSettings) *ModelBuilder {
func (mb *ModelBuilder) WithSettings(settings core.Settings) *ModelBuilder {
mb.model.settings = settings
return mb
}

View File

@ -5,275 +5,25 @@ import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
)
// Model.View: Renders the complete editor view including buffer content, line
// numbers, status bar, and command line.
func (m Model) View() string {
win := m.ActiveWindow()
// NOTES:
// One single command line across entire viewport
// Each window has its own line numbers and gutter
// Each window has its own status bar and mode
styles := m.Styles()
options := win.Options
// Draw window
view := viewWindow(win, styles, options, m.Mode())
// Command bar is seperate
cmdBar := drawCommandBar(m)
return view + cmdBar
}
// viewWindow: Renders a single window's content including line numbers and buffer text.
// Each window has its own line numbers, gutter, and viewport dimensions.
func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode) string {
buf := w.Buffer
var view strings.Builder
// Compute window size (y)
start := w.ScrollY
end := w.ScrollY + w.ViewportHeight()
// Draw buffer lines
for lineNum := start; lineNum < end; lineNum++ {
if lineNum < buf.LineCount() {
line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum)
view.WriteString(line)
}
view.WriteRune('\n')
}
// Draw status line
statusBar := drawStatusBar(w, mode)
view.WriteString(statusBar + "\n")
return view.String()
}
// drawLine: Renders a single line with syntax highlighting, cursor, and visual selection.
// Handles gutter, cursor rendering, and visual mode highlighting.
func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode, line string, lineNumber int) string {
runes := []rune(line)
curStyle := styles.CursorStyle(mode)
visStyle := styles.VisualHighlight
var view strings.Builder
// Draw gutter first
gutter := drawGutter(w, styles, options, lineNumber)
view.WriteString(gutter)
// Now draw the line content
for col := 0; col <= len(runes); col++ {
// Current char is cursor
if col == w.Cursor.Col && lineNumber == w.Cursor.Line {
if col < len(runes) {
view.WriteString(curStyle.Render(string(runes[col])))
} else {
view.WriteString(curStyle.Render(" "))
}
// Not cursor, but not end
} else if col < len(runes) {
if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
view.WriteString(visStyle.Render(string(runes[col])))
} else {
view.WriteRune(runes[col])
}
// Allow highlight on blank lines or chars
} else if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
view.WriteString(visStyle.Render(" "))
}
}
return view.String()
}
// drawGutter: Renders the line number gutter with support for both absolute and
// relative line numbers, highlighting the current line differently.
func drawGutter(w *core.Window, styles style.Styles, options core.WinOptions, curLine int) string {
if !(options.Number || options.RelativeNumber) {
return ""
}
// Required vars
var (
view strings.Builder
gutSize int = options.GutterSize - 1 // -1 is for padding
currentLine bool = curLine == w.Cursor.Line
lineNumber int
gutter string
gutterStyle = styles.Gutter
gutterStyleCur = styles.GutterCurrentLine
)
// If we have relative setting, set the numbers relatively
if options.RelativeNumber {
if curLine > w.Cursor.Line {
lineNumber = curLine - w.Cursor.Line
}
if curLine < w.Cursor.Line {
lineNumber = w.Cursor.Line - curLine
}
}
// If we have number setting AND not relative setting OR we are on current line, use current line number
if (options.Number && !options.RelativeNumber) || (options.Number && currentLine) {
lineNumber = curLine + 1
}
// Draw the gutter
gutter = fmt.Sprintf("%*d ", gutSize, lineNumber)
if currentLine {
view.WriteString(gutterStyleCur.Render(gutter))
} else {
view.WriteString(gutterStyle.Render(gutter))
}
return view.String()
// if m.Settings().Number || m.Settings().RelativeNumber {
// var (
// gutter string
// currentLine bool = false
// lineNumber int
// )
//
// if m.Settings().RelativeNumber {
// // Relative line numbers: show distance from cursor, current line shows absolute
// if i > win.Cursor.Line {
// lineNumber = i - win.Cursor.Line
// gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
// } else if i < win.Cursor.Line {
// lineNumber = win.Cursor.Line - i
// gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
// } else {
// // Current line: show absolute number if Number is also set, otherwise show 0
// currentLine = true
// if m.Settings().Number {
// lineNumber = i + 1
// gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
// } else {
// gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, 0)
// }
// }
// } else if m.Settings().Number {
// // Absolute line numbers only
// lineNumber = i + 1
// currentLine = (i == win.Cursor.Line)
// gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
// }
// if currentLine {
// view.WriteString(m.Styles().GutterCurrentLine.Render(gutter))
// } else {
// view.WriteString(m.Styles().Gutter.Render(gutter))
// }
// }
}
// drawStatusBar: Renders the status bar with mode and cursor position,
// padding the middle with spaces to fill the terminal width.
func drawStatusBar(w *core.Window, mode core.Mode) string {
left := leftBar(w, mode)
right := rightBar(w, mode)
diff := w.Width - (len(left) + len(right))
// This happens when the terminal spawns
if diff <= 0 {
return ""
}
middle := strings.Repeat(" ", diff)
return left + middle + right
}
// leftBar: Returns the left side of the status bar showing the current mode.
func leftBar(w *core.Window, mode core.Mode) string {
buf := w.Buffer
return fmt.Sprintf(" %s %s", mode.ToString(), buf.Filename)
}
// rightBar: Returns the right side of the status bar showing cursor position
// and selection count in visual mode.
func rightBar(w *core.Window, mode core.Mode) (bar string) {
if mode.IsVisualMode() {
lineCount := max(w.Anchor.Line, w.Cursor.Line) - min(w.Anchor.Line, w.Cursor.Line) + 1
bar = fmt.Sprintf("%d:%d <%d>", w.Cursor.Line+1, w.Cursor.Col+1, lineCount)
} else {
bar = fmt.Sprintf("%d:%d ", w.Cursor.Line+1, w.Cursor.Col+1)
}
buf := w.Buffer
bar = fmt.Sprintf("%s %s", buf.Filetype, bar)
return
}
// drawCommandBar: Renders the command line showing command input, errors, or
// output depending on the current mode and state.
func drawCommandBar(m Model) string {
// Compute left bar (command side)
var leftBar string
if m.Mode() == core.CommandMode {
leftBar = ":"
cmd := m.Command()
cur := m.CommandCursor()
for i := 0; i < len(cmd); i++ {
if i == cur {
leftBar += m.Styles().CursorStyle(m.Mode()).Render(string(cmd[i]))
} else {
leftBar += string(cmd[i])
}
}
// Cursor at end of command
if cur >= len(cmd) {
leftBar += m.Styles().CursorStyle(m.Mode()).Render(" ")
}
// bar = fmt.Sprintf("%s %d", bar, cur)
} else if m.CommandError() != nil {
leftBar = m.Styles().CommandError.Render(m.CommandError().Error())
} else if strings.TrimSpace(m.CommandOutput()) != "" {
leftBar = m.CommandOutput()
} else if strings.TrimSpace(m.Command()) != "" {
leftBar = fmt.Sprintf(":%s", m.Command())
}
// Compute right bar
var rightBar string
if len(m.input.Pending()) > 0 {
width := 10 // Size of the block to display
rightBar = fmt.Sprintf("%-*s", width, m.input.Pending())
}
dif := m.termWidth - (len(leftBar) + len(rightBar))
bar := leftBar + strings.Repeat(" ", dif) + rightBar
return bar
}
// posInsideSelection: Returns true if the given position is inside the current
// visual selection, handling all three visual modes differently.
func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool {
switch mode {
func posInsideSelection(m Model, col, line int) bool {
win := m.ActiveWindow()
switch m.Mode() {
case core.VisualLineMode:
startY := min(w.Anchor.Line, w.Cursor.Line)
endY := max(w.Anchor.Line, w.Cursor.Line)
startY := min(win.Anchor.Line, win.Cursor.Line)
endY := max(win.Anchor.Line, win.Cursor.Line)
return line >= startY && line <= endY
case core.VisualMode:
ax := w.Anchor.Col
ay := w.Anchor.Line
ax := win.Anchor.Col
ay := win.Anchor.Line
cx := w.Cursor.Col
cy := w.Cursor.Line
cx := win.Cursor.Col
cy := win.Cursor.Line
// Normalize so start is always before end in document order
var startX, startY, endX, endY int
@ -291,10 +41,10 @@ func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool {
return afterStart && beforeEnd
case core.VisualBlockMode:
startX := min(w.Anchor.Col, w.Cursor.Col)
startY := min(w.Anchor.Line, w.Cursor.Line)
endX := max(w.Anchor.Col, w.Cursor.Col)
endY := max(w.Anchor.Line, w.Cursor.Line)
startX := min(win.Anchor.Col, win.Cursor.Col)
startY := min(win.Anchor.Line, win.Cursor.Line)
endX := max(win.Anchor.Col, win.Cursor.Col)
endY := max(win.Anchor.Line, win.Cursor.Line)
return col >= startX && col <= endX &&
line >= startY && line <= endY
@ -303,3 +53,183 @@ func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool {
return false
}
}
// posIsAnchor: Returns true if the given position matches the anchor position
// used for visual mode debugging/rendering.
func posIsAnchor(m Model, col, line int) bool {
win := m.ActiveWindow()
ax := win.Anchor.Col
ay := win.Anchor.Line
return col == ax && line == ay
}
// Model.View: Renders the complete editor view including buffer content, line
// numbers, status bar, and command line.
func (m Model) View() string {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
var view strings.Builder
viewportHeight := win.Height - 2
start := win.ScrollY
end := win.ScrollY + viewportHeight
for i := start; i < end; i++ {
if i < buf.LineCount() {
if m.Settings().Number || m.Settings().RelativeNumber {
var (
gutter string
currentLine bool = false
lineNumber int
)
if m.Settings().RelativeNumber {
// Relative line numbers: show distance from cursor, current line shows absolute
if i > win.Cursor.Line {
lineNumber = i - win.Cursor.Line
gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
} else if i < win.Cursor.Line {
lineNumber = win.Cursor.Line - i
gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
} else {
// Current line: show absolute number if Number is also set, otherwise show 0
currentLine = true
if m.Settings().Number {
lineNumber = i + 1
gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
} else {
gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, 0)
}
}
} else if m.Settings().Number {
// Absolute line numbers only
lineNumber = i + 1
currentLine = (i == win.Cursor.Line)
gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
}
if currentLine {
view.WriteString(m.Styles().GutterCurrentLine.Render(gutter))
} else {
view.WriteString(m.Styles().Gutter.Render(gutter))
}
}
runes := []rune(buf.Lines[i])
for x := 0; x <= len(runes); x++ {
if win.Cursor.Line == i && win.Cursor.Col == x {
if x < len(runes) {
view.WriteString(m.Styles().CursorStyle(m.Mode()).Render(string(runes[x])))
} else {
view.WriteString(m.Styles().CursorStyle(m.Mode()).Render(" "))
}
} else if x < len(runes) {
if m.Mode().IsVisualMode() && posIsAnchor(m, x, i) {
view.WriteString(m.Styles().VisualAnchor.Render(string(runes[x])))
} else if m.Mode().IsVisualMode() && posInsideSelection(m, x, i) {
view.WriteString(m.Styles().VisualHighlight.Render(string(runes[x])))
} else {
view.WriteRune(runes[x])
}
// To highlight blank lines when in visual mode
} else if m.Mode().IsVisualMode() && posInsideSelection(m, x, i) {
view.WriteString(m.Styles().VisualHighlight.Render(" "))
}
}
} else {
// Empty lines beyond file content
if m.Settings().Number || m.Settings().RelativeNumber {
format := fmt.Sprintf("%%-%ds ", m.Settings().GutterSize-1)
fmt.Fprintf(&view, format, "~")
} else {
view.WriteString("~")
}
}
view.WriteString("\n")
}
view.WriteString(drawStatusBar(m))
view.WriteString("\n")
view.WriteString(drawCommandBar(m))
return view.String()
}
func viewWindow(w core.Window) string {
var view string
return view
}
// drawStatusBar: Renders the status bar with mode and cursor position,
// padding the middle with spaces to fill the terminal width.
func drawStatusBar(m Model) string {
left := leftBar(m)
right := rightBar(m)
diff := m.termWidth - (len(left) + len(right))
// This happens when the terminal spawns
if diff <= 0 {
return ""
}
middle := strings.Repeat(" ", diff)
return left + middle + right
}
// leftBar: Returns the left side of the status bar showing the current mode.
func leftBar(m Model) string {
return fmt.Sprintf(" %s", m.Mode().ToString())
}
// rightBar: Returns the right side of the status bar showing cursor position
// and selection count in visual mode.
func rightBar(m Model) (bar string) {
win := m.ActiveWindow()
if m.Mode().IsVisualMode() {
lineCount := max(win.Anchor.Line, win.Cursor.Line) - min(win.Anchor.Line, win.Cursor.Line) + 1
bar = fmt.Sprintf("%d:%d <%d>", win.Cursor.Line, win.Cursor.Col, lineCount)
} else {
bar = fmt.Sprintf("%d:%d ", win.Cursor.Line, win.Cursor.Col)
}
return
}
// drawCommandBar: Renders the command line showing command input, errors, or
// output depending on the current mode and state.
func drawCommandBar(m Model) (bar string) {
if m.Mode() == core.CommandMode {
bar = ":"
cmd := m.Command()
cur := m.CommandCursor()
for i := 0; i < len(cmd); i++ {
if i == cur {
bar += m.Styles().CursorStyle(m.Mode()).Render(string(cmd[i]))
} else {
bar += string(cmd[i])
}
}
// Cursor at end of command
if cur >= len(cmd) {
bar += m.Styles().CursorStyle(m.Mode()).Render(" ")
}
// bar = fmt.Sprintf("%s %d", bar, cur)
} else if m.CommandError() != nil {
bar = m.Styles().CommandError.Render(m.CommandError().Error())
} else if strings.TrimSpace(m.CommandOutput()) != "" {
bar = m.CommandOutput()
} else if strings.TrimSpace(m.Command()) != "" {
bar = fmt.Sprintf(":%s", m.Command())
} else if len(m.input.Pending()) > 0 {
// Get width of window and padding
rep := m.ActiveWindow().Width - 10 // 10 is padding
bar = fmt.Sprintf("%s%s", strings.Repeat(" ", rep), m.input.Pending())
}
return bar
}