From c7378e308a10e489f9d53f9d0577984bb51d0f27 Mon Sep 17 00:00:00 2001 From: lecheel Date: Sun, 23 Aug 2026 07:50:49 +0800 Subject: [PATCH] feat: implement marks system and jump toggle - Add ability to set and jump to marks - Implement marks popup legend for navigation - Add jump toggle (pingpong) functionality - Display marks in the line number gutter --- cmd/main.go | 5 ++ commands/definitions.go | 3 + config/config.go | 3 + core.go | 38 +++++++++ editor.go | 4 + movements_cmds.go | 27 +++++++ ui/marks_popup.go | 171 ++++++++++++++++++++++++++++++++++++++++ ui/window.go | 29 ++++++- window.go | 23 ++++-- 9 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 ui/marks_popup.go diff --git a/cmd/main.go b/cmd/main.go index 59f0466..9e4366d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,6 +16,7 @@ import ( "github.com/firstrow/wig/config" "github.com/firstrow/wig/metrics" "github.com/firstrow/wig/render" + "github.com/firstrow/wig/ui" ) func main() { @@ -51,6 +52,10 @@ func main() { } } + wig.MarksPopupFactory = func(ctx wig.Context, marks map[rune]wig.Cursor) { + ui.MarksPopupInit(ctx, marks) + } + editor := wig.NewEditor( render.NewMView(tscreen, 0, 0, w, h), keys, diff --git a/commands/definitions.go b/commands/definitions.go index c1f1045..74f6a32 100644 --- a/commands/definitions.go +++ b/commands/definitions.go @@ -26,6 +26,9 @@ func init() { wig.AllCommands["CmdDeleteLine"] = wig.CmdDefinition{Desc: "Delete line", Fn: wig.CmdDeleteLine} wig.AllCommands["CmdGitHunkPreview"] = wig.CmdDefinition{Desc: "Preview git hunk", Fn: CmdGitHunkPreview} wig.AllCommands["CmdMRUBufferPicker"] = wig.CmdDefinition{Desc: "MRU Buffer Picker", Fn: CmdMRUBufferPicker} + wig.AllCommands["CmdSetMark"] = wig.CmdDefinition{Desc: "Set mark", Fn: wig.CmdSetMark} + wig.AllCommands["CmdGotoMark"] = wig.CmdDefinition{Desc: "Go to mark", Fn: wig.CmdGotoMark} + wig.AllCommands["CmdJumpToggle"] = wig.CmdDefinition{Desc: "Toggle jump", Fn: wig.CmdJumpToggle} wig.AllCommands["CmdCheckHealth"] = wig.CmdDefinition{Desc: "Check health of dependencies", Fn: CmdCheckHealth} wig.AllCommands["checkhealth"] = wig.CmdDefinition{Desc: "Check health of dependencies", Fn: CmdCheckHealth} diff --git a/config/config.go b/config/config.go index 1245344..62d62aa 100644 --- a/config/config.go +++ b/config/config.go @@ -187,6 +187,9 @@ func DefaultKeyMap() wig.ModeKeyMap { "q": wig.CmdMacroRecord, "@": wig.CmdMacroPlay, ".": wig.CmdMacroRepeat, + "m": wig.CmdSetMark, + "`": wig.CmdGotoMark, + "'": wig.CmdGotoMark, "c": wig.KeyMap{ "$": wig.CmdChangeEndOfLine, "c": wig.CmdChangeLine, diff --git a/core.go b/core.go index 00bb492..568400a 100644 --- a/core.go +++ b/core.go @@ -787,3 +787,41 @@ func CmdBufferLast(ctx Context) { func CmdPaste(ctx Context) { panic(1) } + +// CmdSetMark waits for a character input and sets a mark at the current cursor position. +func CmdSetMark(ctx Context) func(Context) { + return func(ctx Context) { + charStr := ctx.Char + if strings.HasPrefix(charStr, "shift+") { + charStr = strings.TrimPrefix(charStr, "shift+") + } + if len(charStr) == 0 { + return + } + r := []rune(charStr)[0] + win := ctx.Win + if win == nil { + win = ctx.Editor.ActiveWindow() + } + if win == nil { + return + } + if win.Marks == nil { + win.Marks = make(map[rune]Cursor) + } + cur := ContextCursorGet(ctx) + win.Marks[r] = *cur + ctx.Editor.EchoMessage("Mark '" + string(r) + "' set") + } +} + +// CmdGotoMark opens the marks popup legend. +func CmdGotoMark(ctx Context) { + win := ctx.Win + if win == nil { + win = ctx.Editor.ActiveWindow() + } + if MarksPopupFactory != nil && win != nil { + MarksPopupFactory(ctx, win.Marks) + } +} diff --git a/editor.go b/editor.go index b86ea9d..05ea05d 100644 --- a/editor.go +++ b/editor.go @@ -50,6 +50,10 @@ type Context struct { type AutocompleteFn func(Context) bool +// MarksPopupFactory allows the `ui` package to register a popup for marks +// without causing a circular import. +var MarksPopupFactory func(ctx Context, marks map[rune]Cursor) + var EditorInst *Editor type Layout int diff --git a/movements_cmds.go b/movements_cmds.go index 9926561..998bb9c 100644 --- a/movements_cmds.go +++ b/movements_cmds.go @@ -131,6 +131,7 @@ func CmdGotoLine0(ctx Context) { count := max(ctx.Count, 1) cur := ContextCursorGet(ctx) defer CmdEnsureCursorVisible(ctx) + ctx.Editor.ActiveWindow().Jumps.Push(ctx.Buf, cur) cur.Line = min(int(count)-1, ctx.Buf.Lines.Len-1) ctx.Editor.ActiveWindow().Jumps.Push(ctx.Buf, cur) } @@ -251,6 +252,7 @@ func ParseFileLocation(text string, cursor int) (filename string, line, ch int) func CmdGotoLineEndOfFile(ctx Context) { cur := ContextCursorGet(ctx) defer CmdEnsureCursorVisible(ctx) + ctx.Editor.ActiveWindow().Jumps.Push(ctx.Buf, cur) cur.Line = ctx.Buf.Lines.Len - 1 ctx.Editor.ActiveWindow().Jumps.Push(ctx.Buf, cur) } @@ -580,6 +582,31 @@ func CmdJumpForward(ctx Context) { CmdCursorCenter(ctx) } +// CmdJumpToggle toggles between the previous and current jump locations (pingpong). +func CmdJumpToggle(ctx Context) { + win := ctx.Editor.ActiveWindow() + if win == nil || win.Jumps == nil { + return + } + + // If the user moved around on the current jump point, push the position before toggling + if win.buf != nil { + cur := WindowCursorGet(win, win.buf) + if win.Jumps.List.Last() != nil && win.Jumps.current == win.Jumps.List.Last() { + if win.Jumps.List.Last().Value.Cursor.Line != cur.Line { + win.Jumps.Push(win.buf, cur) + } + } + } + + if win.Jumps.current != nil && win.Jumps.current != win.Jumps.List.Last() { + win.Jumps.JumpForward() + } else { + win.Jumps.JumpBack() + } + CmdCursorCenter(ctx) +} + // Cycle between last two buffers in jump list func CmdBufferCycle(ctx Context) { last := ctx.Editor.ActiveWindow().Jumps.List.Last() diff --git a/ui/marks_popup.go b/ui/marks_popup.go new file mode 100644 index 0000000..35b24cc --- /dev/null +++ b/ui/marks_popup.go @@ -0,0 +1,171 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + + "github.com/firstrow/wig" + "github.com/gdamore/tcell/v2" +) + +type MarksPopupWidget struct { + e *wig.Editor + keymap *wig.KeyHandler + items []markItem +} + +type markItem struct { + Mark rune + Line int + Text string +} + +func (u *MarksPopupWidget) Plane() wig.RenderPlane { return wig.PlaneEditor } +func (u *MarksPopupWidget) Mode() wig.Mode { return wig.MODE_NORMAL } +func (u *MarksPopupWidget) Keymap() *wig.KeyHandler { return u.keymap } + +func MarksPopupInit(ctx wig.Context, marks map[rune]wig.Cursor) { + widget := &MarksPopupWidget{ + e: ctx.Editor, + } + + var keys []rune + for k := range marks { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + + km := wig.KeyMap{ + "Esc": func(ctx wig.Context) { + ctx.Editor.PopUi() + }, + // Double backtick / quote (pingpong): jump back and forward + "`": func(ctx wig.Context) { + ctx.Editor.PopUi() + wig.CmdJumpToggle(ctx) + }, + "'": func(ctx wig.Context) { + ctx.Editor.PopUi() + wig.CmdJumpToggle(ctx) + }, + } + + for _, k := range keys { + cur := marks[k] + line := wig.CursorLineByNum(ctx.Buf, cur.Line) + text := "" + if line != nil { + text = strings.TrimRight(line.Value.String(), "\n") + text = strings.TrimSpace(text) + if len(text) > 60 { + text = text[:60] + "..." + } + } + + mark := k + targetCur := cur + widget.items = append(widget.items, markItem{ + Mark: mark, + Line: cur.Line + 1, + Text: text, + }) + + jumpFn := func(ctx wig.Context) { + ctx.Editor.PopUi() + win := ctx.Win + if win == nil { + win = ctx.Editor.ActiveWindow() + } + win.VisitBuffer(ctx, targetCur) + wig.CmdEnsureCursorVisible(ctx) + } + + markStr := string(mark) + km[markStr] = jumpFn + km["shift+"+markStr] = jumpFn + } + + widget.keymap = wig.NewKeyHandler(wig.ModeKeyMap{wig.MODE_NORMAL: km}) + ctx.Editor.PushUi(widget) +} + +func (u *MarksPopupWidget) Render(view wig.View) { + vw, vh := view.Size() + + lines := []string{} + if len(u.items) == 0 { + lines = append(lines, " No marks set. Press ` for pingpong, Esc to close. ") + } else { + for _, item := range u.items { + s := fmt.Sprintf(" '%c' line %-4d │ %s ", item.Mark, item.Line, item.Text) + lines = append(lines, s) + } + } + + boxH := len(lines) + 1 + boxW := int(float32(vw) * 0.85) + if boxW > vw-4 { + boxW = vw - 4 + } + if boxW < 40 { + boxW = min(vw, 40) + } + + x := (vw - boxW) / 2 + if x < 0 { + x = 0 + } + y := vh - boxH - 2 + if y < 0 { + y = 0 + } + + style := wig.Color("default") + drawBox(view, x, y, x+boxW, y+boxH, style) + + titleStyle := wig.Color("ui.popup.title") + if titleStyle == style { + titleStyle = wig.Color("ui.linenr.selected") + } + view.SetContent(x+2, y, " Marks (` or ' for pingpong) ", titleStyle) + + textStyle := wig.Color("default") + markStyle := wig.Color("ui.mark") + if markStyle == style { + markStyle = style.Foreground(tcell.ColorYellow).Bold(true) + } + lineNrStyle := wig.Color("ui.linenr") + + if len(u.items) == 0 { + for i, line := range lines { + view.SetContent(x+2, y+1+i, line, textStyle) + } + } else { + for i, item := range u.items { + cx := x + 2 + row := y + 1 + i + + view.SetContent(cx, row, "'", textStyle) + cx++ + + view.SetContent(cx, row, string(item.Mark), markStyle) + cx += len([]rune(string(item.Mark))) + + view.SetContent(cx, row, "' line ", textStyle) + cx += 8 + + lnStr := fmt.Sprintf("%-4d", item.Line) + view.SetContent(cx, row, lnStr, lineNrStyle) + cx += len(lnStr) + + view.SetContent(cx, row, " │ ", textStyle) + cx += 3 + + avail := (x + boxW - 1) - cx + if avail > 0 { + view.SetContent(cx, row, truncate(item.Text, avail), textStyle) + } + } + } +} diff --git a/ui/window.go b/ui/window.go index 5f0ccf3..9bf73cc 100644 --- a/ui/window.go +++ b/ui/window.go @@ -42,7 +42,7 @@ func WindowRender(e *wig.Editor, view wig.View, win *wig.Window) { signColWidth := 2 lineNumWidth := 0 if e.Config.ShowLineNumbers { - lineNumWidth = len(fmt.Sprintf("%d", buf.CountLines())) + 1 + lineNumWidth = len(fmt.Sprintf("%d", buf.CountLines())) + 2 } blameColWidth := 0 if buf.BlameEnabled && len(buf.BlameLines) > 0 { @@ -66,6 +66,13 @@ func WindowRender(e *wig.Editor, view wig.View, win *wig.Window) { tsNodeCursor = buf.Highlighter.ForRange(uint32(startLine), startLine+uint32(termHeight)) } + lineMarks := make(map[int]rune) + if win != nil && win.Marks != nil { + for r, mCur := range win.Marks { + lineMarks[mCur.Line] = r + } + } + // Precalculate visual block bounds for efficient rendering var minVisCol, maxVisCol int isVisualBlock := buf.Mode() == wig.MODE_VISUAL_BLOCK && buf.Selection != nil @@ -129,10 +136,24 @@ func WindowRender(e *wig.Editor, view wig.View, win *wig.Window) { } if xCur >= 0 && xCur < termWidth && y >= 0 && y < termHeight { + style := lineNumTextStyle if lineNum == cur.Line { - view.SetContent(xCur, y, fmt.Sprintf("%d", lnNum), lineNumTextStyleSelected) + style = lineNumTextStyleSelected + } + markRune, hasMark := lineMarks[lineNum] + if hasMark { + markStyle := wig.Color("ui.mark") + if markStyle == wig.Color("default") { + markStyle = wig.Color("diff.plus") + } + if lineNum == cur.Line { + markStyle = wig.ApplyBg("ui.cursorline", markStyle) + } + view.SetContent(xCur, y, string(markRune), markStyle) + view.SetContent(xCur+1, y, fmt.Sprintf("%d", lnNum), style) } else { - view.SetContent(xCur, y, fmt.Sprintf("%d", lnNum), lineNumTextStyle) + view.SetContent(xCur, y, " ", style) + view.SetContent(xCur+1, y, fmt.Sprintf("%d", lnNum), style) } } } @@ -317,7 +338,7 @@ func WindowTextPadding(e *wig.Editor, buf *wig.Buffer) int { lineNumWidth := 0 if e.Config.ShowLineNumbers { - lineNumWidth = len(fmt.Sprintf("%d", buf.CountLines())) + 1 + lineNumWidth = len(fmt.Sprintf("%d", buf.CountLines())) + 2 } blameColWidth := 0 diff --git a/window.go b/window.go index 634a9fb..f59075f 100644 --- a/window.go +++ b/window.go @@ -3,6 +3,7 @@ package wig type Window struct { buf *Buffer // active buffer cursors map[*Buffer]*Cursor + Marks map[rune]Cursor Jumps *Jumps } @@ -12,21 +13,24 @@ func (win *Window) VisitBuffer(ctx Context, cursor ...Cursor) { return } - cur := WindowCursorGet(win, win.buf) + // 1. Record position before jumping away if win.buf != nil { + cur := WindowCursorGet(win, win.buf) win.Jumps.Push(win.buf, cur) } + // 2. Set new buffer and target cursor if len(cursor) > 0 { - newCur := &Cursor{} - newCur.Line = cursor[0].Line - newCur.Char = cursor[0].Char - newCur.ScrollOffset = cursor[0].ScrollOffset - win.cursors[ctx.Buf] = newCur + target := WindowCursorGet(win, ctx.Buf) + target.Line = cursor[0].Line + target.Char = cursor[0].Char + target.ScrollOffset = cursor[0].ScrollOffset + win.cursors[ctx.Buf] = target } - win.Jumps.Push(ctx.Buf, cur) win.buf = ctx.Buf + targetCur := WindowCursorGet(win, ctx.Buf) + win.Jumps.Push(ctx.Buf, targetCur) ctx.Win = win CmdCursorCenter(ctx) @@ -46,11 +50,13 @@ func (win *Window) Buffer() *Buffer { // Specify parent window to inherit cursors func CreateWindow(parent *Window) *Window { cursors := map[*Buffer]*Cursor{} + marks := make(map[rune]Cursor) w := &Window{ Jumps: &Jumps{ List: List[Jump]{}, }, cursors: cursors, + Marks: marks, } if parent != nil { for k, v := range parent.cursors { @@ -61,6 +67,9 @@ func CreateWindow(parent *Window) *Window { ScrollOffset: v.ScrollOffset, } } + for k, v := range parent.Marks { + marks[k] = v + } w.buf = parent.buf w.cursors = cursors }