diff --git a/internal/app/commands_editor.go b/internal/app/commands_editor.go index 99765806..82243b7c 100644 --- a/internal/app/commands_editor.go +++ b/internal/app/commands_editor.go @@ -483,6 +483,23 @@ func registerEditorCommands(app *App) { Handler: func() { app.EditorGroup.TitleCase() }, }) + reg.Register(command.Command{ + ID: "bookmark.toggle", Title: "Toggle Bookmark", + Handler: func() { app.EditorGroup.ToggleBookmark() }, + }) + reg.Register(command.Command{ + ID: "bookmark.next", Title: "Next Bookmark", + Handler: func() { app.EditorGroup.NextBookmark() }, + }) + reg.Register(command.Command{ + ID: "bookmark.prev", Title: "Previous Bookmark", + Handler: func() { app.EditorGroup.PrevBookmark() }, + }) + reg.Register(command.Command{ + ID: "bookmark.clearAll", Title: "Clear All Bookmarks", + Handler: func() { app.EditorGroup.ClearBookmarks() }, + }) + reg.Register(command.Command{ ID: "editor.quit", Title: "Quit", Handler: app.Quit, diff --git a/internal/app/theme.go b/internal/app/theme.go index 659ab36f..7b7b6b9f 100644 --- a/internal/app/theme.go +++ b/internal/app/theme.go @@ -37,6 +37,7 @@ func BuildStyleMap(theme config.ThemeConfig) term.StyleMap { applyStyleDef(&m, term.StyleSearchMatch, theme.Editor.SearchMatch) applyStyleDef(&m, term.StyleSearchActive, theme.Editor.SearchActive) applyStyleDef(&m, term.StyleBracketMatch, theme.Editor.BracketMatch) + applyStyleDef(&m, term.StyleBookmark, theme.Editor.Bookmark) applyStyleDef(&m, term.StyleDiffAdded, theme.Diff.Added) applyStyleDef(&m, term.StyleDiffDeleted, theme.Diff.Deleted) applyStyleDef(&m, term.StyleDiffModified, theme.Diff.Modified) diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 580f1469..8794d995 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -194,6 +194,9 @@ func DefaultKeybindings() []KeyBinding { {Key: "ctrl+k 9", Command: "fold.expandAll"}, {Key: "ctrl+k b", Command: "panel.toggle"}, {Key: "ctrl+k j", Command: "editor.joinLines"}, + {Key: "ctrl+k m", Command: "bookmark.toggle"}, + {Key: "ctrl+k n", Command: "bookmark.next"}, + {Key: "ctrl+k ,", Command: "bookmark.prev"}, {Key: "ctrl+t", Command: "terminal.toggle"}, {Key: "alt+t", Command: "terminal.fullscreen"}, {Key: "f10", Command: "menu.file"}, diff --git a/internal/config/theme.go b/internal/config/theme.go index 50e35c50..a51dc543 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -62,6 +62,7 @@ type EditorStyles struct { SearchMatch StyleDef `json:"searchMatch"` SearchActive StyleDef `json:"searchActive"` BracketMatch StyleDef `json:"bracketMatch"` + Bookmark StyleDef `json:"bookmark"` Diagnostics DiagnosticStyles `json:"diagnostics"` } @@ -144,37 +145,37 @@ type HoverStyles struct { } type ThemeConfig struct { - Default StyleDef `json:"default"` - Muted StyleDef `json:"muted"` - Success StyleDef `json:"success"` - Danger StyleDef `json:"danger"` - Warning StyleDef `json:"warning"` - StatusBar StyleDef `json:"statusBar"` - Tabs TabStyles `json:"tabs"` - Sidebar SidebarStyles `json:"sidebar"` - Dialog DialogStyles `json:"dialog"` - Editor EditorStyles `json:"editor"` - Menu MenuStyles `json:"menu"` - Input InputStyles `json:"input"` - Hover HoverStyles `json:"hover"` - Border StyleDef `json:"border"` - Diff DiffStyles `json:"diff"` - Scrollbar StyleDef `json:"scrollbar"` - Syntax SyntaxStyles `json:"syntax"` - Borders BorderChars `json:"borders"` - Terminal TerminalColors `json:"terminal,omitempty"` + Default StyleDef `json:"default"` + Muted StyleDef `json:"muted"` + Success StyleDef `json:"success"` + Danger StyleDef `json:"danger"` + Warning StyleDef `json:"warning"` + StatusBar StyleDef `json:"statusBar"` + Tabs TabStyles `json:"tabs"` + Sidebar SidebarStyles `json:"sidebar"` + Dialog DialogStyles `json:"dialog"` + Editor EditorStyles `json:"editor"` + Menu MenuStyles `json:"menu"` + Input InputStyles `json:"input"` + Hover HoverStyles `json:"hover"` + Border StyleDef `json:"border"` + Diff DiffStyles `json:"diff"` + Scrollbar StyleDef `json:"scrollbar"` + Syntax SyntaxStyles `json:"syntax"` + Borders BorderChars `json:"borders"` + Terminal TerminalColors `json:"terminal,omitempty"` } func DefaultTheme() ThemeConfig { t := ThemeConfig{ Terminal: DefaultTerminalColors(), - Default: StyleDef{Fg: "#fafafa", Bg: "#1f1f1f"}, - Muted: StyleDef{Fg: "#888888"}, + Default: StyleDef{Fg: "#fafafa", Bg: "#1f1f1f"}, + Muted: StyleDef{Fg: "#888888"}, Menu: MenuStyles{ Active: StyleDef{Fg: "#ffffff", Bg: "#505050", Bold: true}, }, - StatusBar: StyleDef{}, + StatusBar: StyleDef{}, Tabs: TabStyles{ Active: StyleDef{Fg: "#ffffff", Bold: true}, @@ -254,6 +255,7 @@ func (t *ThemeConfig) ResolveColors() { } fillFg(&t.Hover.Bold, t.Default.Fg) fillFg(&t.Hover.Code, t.Syntax.String.Fg) + fillFg(&t.Editor.Bookmark, "#d7ba7d") } func fillFg(s *StyleDef, color string) { @@ -266,4 +268,4 @@ func fillBg(s *StyleDef, color string) { if s.Bg == "" { s.Bg = color } -} \ No newline at end of file +} diff --git a/internal/term/screen.go b/internal/term/screen.go index a684f679..0abd6de4 100644 --- a/internal/term/screen.go +++ b/internal/term/screen.go @@ -49,6 +49,7 @@ const ( StyleInputAction StyleHoverBold StyleHoverCode + StyleBookmark ) // DirectColor holds an RGBA color for terminal emulator output. @@ -62,7 +63,7 @@ type DirectColor struct { type CellAttr byte const ( - CellAttrBold CellAttr = 1 << iota + CellAttrBold CellAttr = 1 << iota CellAttrUnderline CellAttrItalic CellAttrReverse @@ -79,17 +80,17 @@ type Cell struct { // Direct-style fields for terminal emulator cells. // When Direct is true, Fg/Bg/Attrs are used instead of Style. - Direct bool - Fg DirectColor - Bg DirectColor - Attrs CellAttr + Direct bool + Fg DirectColor + Bg DirectColor + Attrs CellAttr } // CursorStyle represents the shape of the text cursor. type CursorStyle int const ( - CursorStyleBlinkingBar CursorStyle = iota // default + CursorStyleBlinkingBar CursorStyle = iota // default CursorStyleSteadyBar CursorStyleBlinkingBlock CursorStyleSteadyBlock @@ -149,8 +150,8 @@ func (m *MockScreen) SetCell(x, y int, c Cell) { m.Cells[[2]int{x, y}] = c } -func (m *MockScreen) Show() {} -func (m *MockScreen) Clear() { m.Cells = make(map[[2]int]Cell) } -func (m *MockScreen) ShowCursor(x, y int) {} -func (m *MockScreen) HideCursor() {} +func (m *MockScreen) Show() {} +func (m *MockScreen) Clear() { m.Cells = make(map[[2]int]Cell) } +func (m *MockScreen) ShowCursor(x, y int) {} +func (m *MockScreen) HideCursor() {} func (m *MockScreen) SetCursorStyle(CursorStyle) {} diff --git a/internal/term/tcell_screen.go b/internal/term/tcell_screen.go index 9e8b529f..ab385836 100644 --- a/internal/term/tcell_screen.go +++ b/internal/term/tcell_screen.go @@ -4,7 +4,7 @@ import ( "github.com/gdamore/tcell/v2" ) -const StyleCount = 48 +const StyleCount = 49 type StyleMap [StyleCount]tcell.Style @@ -120,7 +120,7 @@ func (t *TcellScreen) HideCursor() { var cursorStyleMap = map[CursorStyle]tcell.CursorStyle{ CursorStyleBlinkingBar: tcell.CursorStyleBlinkingBar, - CursorStyleSteadyBar: tcell.CursorStyleSteadyBar, + CursorStyleSteadyBar: tcell.CursorStyleSteadyBar, CursorStyleBlinkingBlock: tcell.CursorStyleBlinkingBlock, CursorStyleSteadyBlock: tcell.CursorStyleSteadyBlock, CursorStyleBlinkingUnderline: tcell.CursorStyleBlinkingUnderline, diff --git a/internal/ui/editor_group.go b/internal/ui/editor_group.go index 923acd02..6e4c97a8 100644 --- a/internal/ui/editor_group.go +++ b/internal/ui/editor_group.go @@ -54,6 +54,7 @@ type editorTab struct { TabSize int Content Widget Pinned bool + Bookmarks map[int]bool } type EditorGroupWidget struct { @@ -384,6 +385,7 @@ func (g *EditorGroupWidget) SwitchTab(idx int) { func (g *EditorGroupWidget) saveMultiState() { if t := g.activeTab(); t != nil && t.Content == nil { t.Multi = g.Editor.Multi + t.Bookmarks = g.Editor.Bookmarks } } @@ -842,6 +844,32 @@ func (g *EditorGroupWidget) TitleCase() { } } +func (g *EditorGroupWidget) ToggleBookmark() { + if g.IsEditorActive() { + g.Editor.ToggleBookmark() + g.saveMultiState() + } +} + +func (g *EditorGroupWidget) NextBookmark() { + if g.IsEditorActive() { + g.Editor.NextBookmark() + } +} + +func (g *EditorGroupWidget) PrevBookmark() { + if g.IsEditorActive() { + g.Editor.PrevBookmark() + } +} + +func (g *EditorGroupWidget) ClearBookmarks() { + if g.IsEditorActive() { + g.Editor.ClearBookmarks() + g.saveMultiState() + } +} + func (g *EditorGroupWidget) IsMultiCursorActive() bool { return g.IsEditorActive() && g.Editor.isMultiActive() } @@ -914,6 +942,7 @@ func (g *EditorGroupWidget) syncTabs() { g.Editor.Highlighter = t.Highlighter g.Editor.Diagnostics = t.Diagnostics g.Editor.Folds = t.Folds + g.Editor.Bookmarks = t.Bookmarks g.Editor.buildDiagIndex() g.Editor.InvalidateMaxLineWidth() if t.TabSize > 0 { diff --git a/internal/ui/editor_widget.go b/internal/ui/editor_widget.go index da476cb7..aed982c0 100644 --- a/internal/ui/editor_widget.go +++ b/internal/ui/editor_widget.go @@ -54,6 +54,7 @@ type EditorPaneWidget struct { cachedVisibleLines []int searchByLine map[int][]int diagByLine map[int][]int + Bookmarks map[int]bool } func NewEditorPaneWidget(buf *buffer.Buffer, cur *cursor.Cursor, vp *view.Viewport) *EditorPaneWidget { @@ -253,6 +254,9 @@ func (e *EditorPaneWidget) Render(surface *RenderSurface) { } } } + if lineIdx < totalLines && e.Bookmarks != nil && e.Bookmarks[lineIdx] { + surface.SetCell(0, y, term.Cell{Ch: '●', Style: term.StyleBookmark}) + } } if lineIdx < totalLines { @@ -2177,7 +2181,6 @@ func (e *EditorPaneWidget) SplitSelectionToLines() { e.scrollViewport() } - // transformSelection replaces the selected text with the result of applying fn. // It preserves the selection after transformation. func (e *EditorPaneWidget) transformSelection(fn func(string) string) { @@ -2268,3 +2271,79 @@ func (e *EditorPaneWidget) TitleCase() { return string(runes) }) } + +func (e *EditorPaneWidget) ToggleBookmark() { + if e.Bookmarks == nil { + e.Bookmarks = make(map[int]bool) + } + line := e.Cursor.Line + if e.Bookmarks[line] { + delete(e.Bookmarks, line) + } else { + e.Bookmarks[line] = true + } +} + +func (e *EditorPaneWidget) sortedBookmarks() []int { + if len(e.Bookmarks) == 0 { + return nil + } + lines := make([]int, 0, len(e.Bookmarks)) + for line := range e.Bookmarks { + lines = append(lines, line) + } + sort.Ints(lines) + return lines +} + +func (e *EditorPaneWidget) NextBookmark() { + sorted := e.sortedBookmarks() + if len(sorted) == 0 { + return + } + cur := e.Cursor.Line + for _, line := range sorted { + if line > cur { + e.Cursor.Line = line + e.Cursor.Col = 0 + e.clampCursor() + e.scrollViewport() + return + } + } + // Wrap around to first bookmark + e.Cursor.Line = sorted[0] + e.Cursor.Col = 0 + e.clampCursor() + e.scrollViewport() +} + +func (e *EditorPaneWidget) PrevBookmark() { + sorted := e.sortedBookmarks() + if len(sorted) == 0 { + return + } + cur := e.Cursor.Line + for i := len(sorted) - 1; i >= 0; i-- { + if sorted[i] < cur { + e.Cursor.Line = sorted[i] + e.Cursor.Col = 0 + e.clampCursor() + e.scrollViewport() + return + } + } + // Wrap around to last bookmark + e.Cursor.Line = sorted[len(sorted)-1] + e.Cursor.Col = 0 + e.clampCursor() + e.scrollViewport() +} + +func (e *EditorPaneWidget) ClearBookmarks() { + e.Bookmarks = nil +} + +func (e *EditorPaneWidget) HasBookmarks() bool { + return len(e.Bookmarks) > 0 +} diff --git a/tests/e2e/bookmark_test.go b/tests/e2e/bookmark_test.go new file mode 100644 index 00000000..778a13eb --- /dev/null +++ b/tests/e2e/bookmark_test.go @@ -0,0 +1,160 @@ +package e2e + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBookmarkToggle(t *testing.T) { + h := newTestHarness(t, 80, 24) + defer h.stop() + + f := filepath.Join(h.dir, "marks.txt") + os.WriteFile(f, []byte("line1\nline2\nline3\nline4\nline5"), 0644) + h.app.EditorGroup.OpenFile(f) + h.redraw() + + // Toggle bookmark on line 0 + h.exec("bookmark.toggle") + if !h.app.EditorGroup.Editor.Bookmarks[0] { + t.Fatal("expected bookmark on line 0") + } + + // Verify gutter shows bookmark indicator + h.redraw() + screen := h.screenText() + if !strings.Contains(screen, "●") { + t.Errorf("expected bookmark indicator in gutter, got:\n%s", screen) + } + + // Toggle again to remove + h.exec("bookmark.toggle") + if h.app.EditorGroup.Editor.Bookmarks[0] { + t.Fatal("expected bookmark on line 0 to be removed") + } +} + +func TestBookmarkNextPrev(t *testing.T) { + h := newTestHarness(t, 80, 24) + defer h.stop() + + f := filepath.Join(h.dir, "nav.txt") + os.WriteFile(f, []byte("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8"), 0644) + h.app.EditorGroup.OpenFile(f) + h.redraw() + + // Set bookmarks on lines 2 and 5 (0-indexed) + h.app.EditorGroup.Editor.Cursor.Line = 2 + h.exec("bookmark.toggle") + h.app.EditorGroup.Editor.Cursor.Line = 5 + h.exec("bookmark.toggle") + + // Go to line 0 + h.app.EditorGroup.Editor.Cursor.Line = 0 + h.app.EditorGroup.Editor.Cursor.Col = 0 + h.redraw() + + // Next bookmark should go to line 2 + h.exec("bookmark.next") + if h.app.EditorGroup.Editor.Cursor.Line != 2 { + t.Errorf("expected cursor on line 2, got %d", h.app.EditorGroup.Editor.Cursor.Line) + } + + // Next bookmark should go to line 5 + h.exec("bookmark.next") + if h.app.EditorGroup.Editor.Cursor.Line != 5 { + t.Errorf("expected cursor on line 5, got %d", h.app.EditorGroup.Editor.Cursor.Line) + } + + // Next bookmark should wrap to line 2 + h.exec("bookmark.next") + if h.app.EditorGroup.Editor.Cursor.Line != 2 { + t.Errorf("expected cursor to wrap to line 2, got %d", h.app.EditorGroup.Editor.Cursor.Line) + } + + // Prev bookmark should go to line 5 (wrap backward) + h.exec("bookmark.prev") + if h.app.EditorGroup.Editor.Cursor.Line != 5 { + t.Errorf("expected cursor on line 5 after prev, got %d", h.app.EditorGroup.Editor.Cursor.Line) + } + + // Prev bookmark should go to line 2 + h.exec("bookmark.prev") + if h.app.EditorGroup.Editor.Cursor.Line != 2 { + t.Errorf("expected cursor on line 2 after prev, got %d", h.app.EditorGroup.Editor.Cursor.Line) + } +} + +func TestBookmarkClearAll(t *testing.T) { + h := newTestHarness(t, 80, 24) + defer h.stop() + + f := filepath.Join(h.dir, "clear.txt") + os.WriteFile(f, []byte("line1\nline2\nline3"), 0644) + h.app.EditorGroup.OpenFile(f) + h.redraw() + + // Set bookmarks on lines 0 and 2 + h.exec("bookmark.toggle") + h.app.EditorGroup.Editor.Cursor.Line = 2 + h.exec("bookmark.toggle") + + if !h.app.EditorGroup.Editor.HasBookmarks() { + t.Fatal("expected bookmarks to exist") + } + + // Clear all + h.exec("bookmark.clearAll") + + if h.app.EditorGroup.Editor.HasBookmarks() { + t.Fatal("expected all bookmarks to be cleared") + } +} + +func TestBookmarkPerTab(t *testing.T) { + h := newTestHarness(t, 80, 24) + defer h.stop() + + f1 := filepath.Join(h.dir, "tab1.txt") + f2 := filepath.Join(h.dir, "tab2.txt") + os.WriteFile(f1, []byte("file1-line1\nfile1-line2"), 0644) + os.WriteFile(f2, []byte("file2-line1\nfile2-line2"), 0644) + + h.app.EditorGroup.OpenFile(f1) + h.app.EditorGroup.PinActiveTab() + h.redraw() + h.exec("bookmark.toggle") // bookmark line 0 in tab1 + + h.app.EditorGroup.OpenFile(f2) + h.redraw() + + // Tab2 should have no bookmarks + if h.app.EditorGroup.Editor.HasBookmarks() { + t.Fatal("tab2 should have no bookmarks") + } + + // Switch back to tab1 + h.app.EditorGroup.SwitchTab(0) + h.redraw() + + // Tab1 should still have its bookmark + if !h.app.EditorGroup.Editor.Bookmarks[0] { + t.Fatal("tab1 should still have bookmark on line 0") + } +} + +func TestBookmarkNextNoBookmarks(t *testing.T) { + h := newTestHarness(t, 80, 24) + defer h.stop() + + f := filepath.Join(h.dir, "empty.txt") + os.WriteFile(f, []byte("no bookmarks"), 0644) + h.app.EditorGroup.OpenFile(f) + h.redraw() + + // Should be a no-op, not crash + h.exec("bookmark.next") + h.exec("bookmark.prev") +} diff --git a/tests/functional/bookmark.test.js b/tests/functional/bookmark.test.js new file mode 100644 index 00000000..e995a905 --- /dev/null +++ b/tests/functional/bookmark.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as tui from "./tui.js"; +import { createTempDir, createTempFile, cleanupDir } from "./helpers.js"; + +let dir; + +afterEach(() => { + tui.kill(); + if (dir) cleanupDir(dir); +}); + +describe("bookmarks", () => { + it("should toggle bookmark and show indicator", () => { + dir = createTempDir(); + const file = createTempFile(dir, "bookmark.txt", "AAA\nBBB\nCCC\nDDD\nEEE"); + + tui.start(file); + tui.waitFor("AAA"); + + // Toggle bookmark on the first line via command palette + tui.exec("Toggle Bookmark"); + tui.waitStable(); + + const snap = tui.snapshot(); + expect(snap).toContain("●"); + }); + + it("should navigate between bookmarks", () => { + dir = createTempDir(); + const file = createTempFile(dir, "nav.txt", "Line1\nLine2\nLine3\nLine4\nLine5\nLine6\nLine7\nLine8"); + + tui.start(file); + tui.waitFor("Line1"); + + // Bookmark line 1 (cursor starts on line 1) + tui.exec("Toggle Bookmark"); + tui.waitStable(); + + // Move to line 5 and bookmark it + tui.press("arrow_down"); + tui.press("arrow_down"); + tui.press("arrow_down"); + tui.press("arrow_down"); + tui.waitStable(); + tui.exec("Toggle Bookmark"); + tui.waitStable(); + + // Move to line 1 again + tui.press("ctrl+g"); + tui.waitStable(); + tui.type("1"); + tui.press("enter"); + tui.waitStable(); + + // Jump to next bookmark (should go to line 5 since cursor is on line 1 which is bookmarked) + tui.exec("Next Bookmark"); + tui.waitStable(); + + const snap = tui.snapshot(); + // Both bookmarks should be visible + expect(snap).toContain("●"); + }); + + it("should clear all bookmarks", () => { + dir = createTempDir(); + const file = createTempFile(dir, "clear.txt", "AAA\nBBB\nCCC"); + + tui.start(file); + tui.waitFor("AAA"); + + tui.exec("Toggle Bookmark"); + tui.waitStable(); + + let snap = tui.snapshot(); + expect(snap).toContain("●"); + + tui.exec("Clear All Bookmarks"); + tui.waitStable(); + + snap = tui.snapshot(); + expect(snap).not.toContain("●"); + }); +});