From 2bdd6815dd7b133ac5b29bc1aca6d902b6a1a52b Mon Sep 17 00:00:00 2001 From: igaboo Date: Fri, 1 May 2026 03:48:32 -0700 Subject: [PATCH] feat: improve link block editing --- internal/block/block.go | 8 +- internal/block/parse.go | 4 +- internal/block/parse_test.go | 4 +- internal/browser/browser.go | 189 ++++++++++------------ internal/browser/browser_test.go | 57 ++++++- internal/clipboard/clipboard.go | 26 +++ internal/editor/editor.go | 235 ++++++++++++++++++--------- internal/editor/link_modal.go | 69 ++++++++ internal/editor/link_modal_test.go | 151 ++++++++++++++++++ internal/editor/link_render_test.go | 66 ++++++++ internal/editor/render.go | 118 ++++++-------- internal/format/format.go | 41 ++++- internal/ui/picker.go | 8 +- internal/ui/text_input.go | 236 ++++++++++++++++++++++++++++ internal/ui/text_input_test.go | 60 +++++++ 15 files changed, 1012 insertions(+), 260 deletions(-) create mode 100644 internal/editor/link_modal.go create mode 100644 internal/editor/link_modal_test.go create mode 100644 internal/editor/link_render_test.go create mode 100644 internal/ui/text_input.go create mode 100644 internal/ui/text_input_test.go diff --git a/internal/block/block.go b/internal/block/block.go index fad6d27..1a896d4 100644 --- a/internal/block/block.go +++ b/internal/block/block.go @@ -278,13 +278,13 @@ func ExtractDefinition(content string) (term, definition string) { return first, "" } -// ExtractLink splits a link block's content into its title line -// (first line) and URL (second line). When only a URL is stored, title -// is empty. +// ExtractLink splits a link block's content into its URL line (first line) +// and title (second line). The URL is stored first so the active edit form +// presents it before the title. When only a URL is stored, title is empty. func ExtractLink(content string) (title, url string) { first, rest, found := strings.Cut(content, "\n") if found { - return first, rest + return rest, first } return "", first } diff --git a/internal/block/parse.go b/internal/block/parse.go index 6edb3d8..2666ba6 100644 --- a/internal/block/parse.go +++ b/internal/block/parse.go @@ -222,10 +222,12 @@ func Parse(markdown string) []Block { } // --- Link ([title](url) or bare URL on its own line) --- + // Content is stored URL-first so the edit form presents URL above + // title; when title is empty, only the URL is stored. if title, url, ok := ParseLink(line); ok { content := url if title != "" { - content = title + "\n" + url + content = url + "\n" + title } blocks = append(blocks, Block{Type: Link, Content: content}) i++ diff --git a/internal/block/parse_test.go b/internal/block/parse_test.go index b222ecf..fa56155 100644 --- a/internal/block/parse_test.go +++ b/internal/block/parse_test.go @@ -316,7 +316,7 @@ func TestParse(t *testing.T) { name: "link titled link", input: "[Example](https://example.com)", expect: []Block{ - {Type: Link, Content: "Example\nhttps://example.com"}, + {Type: Link, Content: "https://example.com\nExample"}, }, }, { @@ -339,7 +339,7 @@ func TestParse(t *testing.T) { expect: []Block{ {Type: Paragraph, Content: "above"}, {Type: Paragraph, Content: ""}, - {Type: Link, Content: "Site\nhttps://site.io"}, + {Type: Link, Content: "https://site.io\nSite"}, {Type: Paragraph, Content: ""}, {Type: Paragraph, Content: "below"}, }, diff --git a/internal/browser/browser.go b/internal/browser/browser.go index ef51a5a..50456a5 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -8,8 +8,8 @@ import ( "strings" "time" - tea "charm.land/bubbletea/v2" "charm.land/bubbles/v2/cursor" + tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/oobagi/notebook-cli/internal/clipboard" "github.com/oobagi/notebook-cli/internal/config" @@ -27,7 +27,7 @@ type Config struct { InitialBook string // if set, start at L1 in this notebook RestoreSel *Selection // if set, reposition cursor to this item after load DismissedHints map[string]bool - ShowPreview *bool // from config; nil = default (true) + ShowPreview *bool // from config; nil = default (true) } // Selection represents a note the user chose to open. @@ -40,22 +40,22 @@ type Selection struct { // Model is the Bubble Tea model for the notebook/note browser. type Model struct { - store *storage.Store - level int // 0=notebooks, 1=notes - notebooks []notebookItem - notes []model.Note - currentBook string // selected notebook name - cursor int // current selection index + store *storage.Store + level int // 0=notebooks, 1=notes + notebooks []notebookItem + notes []model.Note + currentBook string // selected notebook name + cursor int // current selection index filter string // fuzzy search filter text filtering bool // whether filter mode is active filterCursor int // cursor position within filter - filtered []int // indices into notebooks/notes after filtering - width int - height int - showHelp bool // help overlay visible - quitting bool - selected *Selection // set when user picks a note to edit - err error + filtered []int // indices into notebooks/notes after filtering + width int + height int + showHelp bool // help overlay visible + quitting bool + selected *Selection // set when user picks a note to edit + err error // Input mode fields (used by create, rename, delete type-to-confirm). inputMode bool @@ -293,9 +293,21 @@ type statusMsg struct{ text string } // only the most recent status message is cleared. type statusTimeoutMsg struct{ generation int } +type clipboardReadErrMsg struct{ err error } + // statusTimeout is the delay before auto-dismissing a transient status message. const statusTimeout = 4 * time.Second +func readClipboardCmd() tea.Cmd { + return func() tea.Msg { + text, err := clipboard.Read() + if err != nil { + return clipboardReadErrMsg{err: err} + } + return tea.PasteMsg{Content: text} + } +} + // Update implements tea.Model. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -359,10 +371,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case clipboardReadErrMsg: + m.statusText = "Paste failed: " + msg.err.Error() + return m, m.scheduleStatusDismiss() + case errMsg: m.err = msg.err return m, nil + case tea.PasteMsg: + return m.handlePaste(msg.Content) + case tea.KeyPressMsg: return m.handleKey(msg) } @@ -377,6 +396,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m Model) handlePaste(text string) (tea.Model, tea.Cmd) { + if m.showSettings && m.settingsEditing { + m.inputCur.IsBlinked = false + m.inputValue, m.inputCursor = ui.InsertLineText(m.inputValue, m.inputCursor, text) + return m, nil + } + if m.inputMode { + m.inputCur.IsBlinked = false + m.inputValue, m.inputCursor = ui.InsertLineText(m.inputValue, m.inputCursor, text) + return m, nil + } + if m.filtering { + m.inputCur.IsBlinked = false + m.filter, m.filterCursor = ui.InsertLineText(m.filter, m.filterCursor, text) + m.applyFilter() + return m, nil + } + return m, nil +} + func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Clear any lingering status text on next keypress. m.statusText = "" @@ -530,87 +569,6 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } } -// lineEdit handles common single-line text editing keys (movement, deletion, -// insertion) on a (string, cursor) pair. Returns the updated string, cursor, -// and whether the key was consumed. Callers handle mode-specific keys (esc, -// enter, tab, up/down) before calling this. -func lineEdit(s string, cursor int, key string, text string) (string, int, bool) { - switch key { - case "left": - if cursor > 0 { - cursor-- - } - case "right": - if cursor < len(s) { - cursor++ - } - case "alt+left", "alt+b": - cursor = wordLeft(s, cursor) - case "alt+right", "alt+f": - cursor = wordRight(s, cursor) - case "home", "ctrl+a": - cursor = 0 - case "end", "ctrl+e": - cursor = len(s) - case "backspace": - if cursor > 0 { - s = s[:cursor-1] + s[cursor:] - cursor-- - } - case "alt+backspace", "ctrl+w": - newPos := wordLeft(s, cursor) - s = s[:newPos] + s[cursor:] - cursor = newPos - case "ctrl+u": - s = s[cursor:] - cursor = 0 - case "ctrl+k": - s = s[:cursor] - case "space": - s = s[:cursor] + " " + s[cursor:] - cursor++ - default: - if len(text) > 0 { - s = s[:cursor] + text + s[cursor:] - cursor += len(text) - } else { - return s, cursor, false - } - } - return s, cursor, true -} - -// wordLeft returns the cursor position of the start of the previous word. -func wordLeft(s string, cursor int) int { - if cursor <= 0 { - return 0 - } - i := cursor - 1 - for i > 0 && s[i] == ' ' { - i-- - } - for i > 0 && s[i-1] != ' ' { - i-- - } - return i -} - -// wordRight returns the cursor position past the end of the next word. -func wordRight(s string, cursor int) int { - n := len(s) - if cursor >= n { - return n - } - i := cursor - for i < n && s[i] != ' ' { - i++ - } - for i < n && s[i] == ' ' { - i++ - } - return i -} - func (m Model) handleFilterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.inputCur.IsBlinked = false // "/" with empty filter dismisses search (toggle behavior). @@ -649,9 +607,11 @@ func (m Model) handleFilterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.cursor++ } return m, nil + case "ctrl+v": + return m, readClipboardCmd() default: prev := m.filter - m.filter, m.filterCursor, _ = lineEdit(m.filter, m.filterCursor, msg.String(), msg.Text) + m.filter, m.filterCursor, _ = ui.EditLine(m.filter, m.filterCursor, msg) if m.filter != prev { m.applyFilter() } @@ -683,8 +643,10 @@ func (m Model) handleInputKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, action(value) } return m, nil + case "ctrl+v": + return m, readClipboardCmd() default: - m.inputValue, m.inputCursor, _ = lineEdit(m.inputValue, m.inputCursor, msg.String(), msg.Text) + m.inputValue, m.inputCursor, _ = ui.EditLine(m.inputValue, m.inputCursor, msg) } return m, nil } @@ -713,8 +675,10 @@ func (m Model) handleSettingsInputKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) return m, action(value) } return m, nil + case "ctrl+v": + return m, readClipboardCmd() default: - m.inputValue, m.inputCursor, _ = lineEdit(m.inputValue, m.inputCursor, msg.String(), msg.Text) + m.inputValue, m.inputCursor, _ = ui.EditLine(m.inputValue, m.inputCursor, msg) } return m, nil } @@ -2380,14 +2344,35 @@ func (m Model) renderStatusBar() string { if m.inputMode && m.showSettings { // Settings text edit: use input bar without border (settings footer provides its own). - return format.StatusBarInput(m.inputPrompt, m.inputValue, m.inputCursor, "Enter confirm \u00B7 Esc cancel", width, !m.inputCur.IsBlinked) + input := ui.NewTextInput(m.inputValue) + input.SetCursor(m.inputCursor) + return input.RenderStatus(ui.TextInputProps{ + Prompt: m.inputPrompt, + Hints: "Enter confirm \u00B7 Esc cancel", + Width: width, + CursorVisible: !m.inputCur.IsBlinked, + }) } if m.inputMode { - return format.FooterInput(m.inputPrompt, m.inputValue, m.inputCursor, "Enter confirm \u00B7 Esc cancel", width, !m.inputCur.IsBlinked) + input := ui.NewTextInput(m.inputValue) + input.SetCursor(m.inputCursor) + return input.RenderFooter(ui.TextInputProps{ + Prompt: m.inputPrompt, + Hints: "Enter confirm \u00B7 Esc cancel", + Width: width, + CursorVisible: !m.inputCur.IsBlinked, + }) } if m.filtering { - return format.FooterInput("Search:", m.filter, m.filterCursor, "Esc clear \u00B7 Enter select", width, !m.inputCur.IsBlinked) + input := ui.NewTextInput(m.filter) + input.SetCursor(m.filterCursor) + return input.RenderFooter(ui.TextInputProps{ + Prompt: "Search:", + Hints: "Esc clear \u00B7 Enter select", + Width: width, + CursorVisible: !m.inputCur.IsBlinked, + }) } left := " " diff --git a/internal/browser/browser_test.go b/internal/browser/browser_test.go index 39486c5..f165f3a 100644 --- a/internal/browser/browser_test.go +++ b/internal/browser/browser_test.go @@ -57,7 +57,7 @@ func initModel(t *testing.T, s *storage.Store) Model { t.Helper() t.Setenv("HOME", t.TempDir()) m := New(Config{ - Store: s, + Store: s, }) // Run Init and process the resulting messages (including batches). @@ -200,6 +200,26 @@ func TestBrowserFilterReducesList(t *testing.T) { } } +func TestBrowserFilterAcceptsPaste(t *testing.T) { + s := setupTestStore(t, map[string][]string{ + "work": {"todo"}, + "home": {"pasta"}, + }) + + m := initModel(t, s) + m = sendRune(t, m, '/') + + updated, _ := m.Update(tea.PasteMsg{Content: "pas\n"}) + m = updated.(Model) + + if m.filter != "pas" { + t.Fatalf("filter = %q, want pasted text", m.filter) + } + if len(m.searchResults) != 1 || m.searchResults[0].note != "pasta" { + t.Fatalf("search results = %+v, want pasted filter to apply", m.searchResults) + } +} + func TestBrowserQuitOnQ(t *testing.T) { s := setupTestStore(t, map[string][]string{ "work": {"todo"}, @@ -369,7 +389,6 @@ func TestBrowserFilterClearOnEsc(t *testing.T) { } } - func TestBrowserHelpToggle(t *testing.T) { s := setupTestStore(t, map[string][]string{ "work": {"todo"}, @@ -800,6 +819,40 @@ func TestBrowserRenameNotebook(t *testing.T) { } } +func TestBrowserInputAcceptsPaste(t *testing.T) { + s := setupTestStore(t, map[string][]string{}) + m := initModel(t, s) + + m = sendRune(t, m, 'n') + if !m.inputMode { + t.Fatal("expected input mode after starting create") + } + + updated, _ := m.Update(tea.PasteMsg{Content: "new notebook\n"}) + m = updated.(Model) + + if m.inputValue != "new notebook" { + t.Fatalf("input value = %q, want pasted text", m.inputValue) + } +} + +func TestBrowserSettingsInputAcceptsPaste(t *testing.T) { + s := setupTestStore(t, map[string][]string{}) + m := initModel(t, s) + m.showSettings = true + m.settingsEditing = true + m.inputMode = true + m.inputValue = "ab" + m.inputCursor = 1 + + updated, _ := m.Update(tea.PasteMsg{Content: "x\ny"}) + m = updated.(Model) + + if m.inputValue != "ax yb" { + t.Fatalf("settings input value = %q, want sanitized pasted text inserted at cursor", m.inputValue) + } +} + func TestBrowserRenameNote(t *testing.T) { s := setupTestStore(t, map[string][]string{ "work": {"old-note"}, diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go index 52cd482..5727c3c 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -20,6 +20,11 @@ func Copy(text string) error { return copyOSC52(text, os.Stdout) } +// Read returns text from the system clipboard. +func Read() (string, error) { + return readSystem() +} + // copyOSC52 writes an OSC 52 escape sequence to w. // Format: \x1b]52;c;\x07 func copyOSC52(text string, w io.Writer) error { @@ -45,3 +50,24 @@ func copySystem(text string) error { cmd.Stdin = strings.NewReader(text) return cmd.Run() } + +// readSystem uses platform-specific commands to read text from the clipboard. +// macOS: pbpaste +// Linux: xclip -selection clipboard -o +func readSystem() (string, error) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("pbpaste") + case "linux": + cmd = exec.Command("xclip", "-selection", "clipboard", "-o") + default: + return "", fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} diff --git a/internal/editor/editor.go b/internal/editor/editor.go index 7ec87eb..2d68eaa 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -82,6 +82,18 @@ type statusTimeoutMsg struct{ generation int } // statusTimeout is the delay before auto-dismissing a transient status message. const statusTimeout = 4 * time.Second +type clipboardReadErrMsg struct{ err error } + +func readClipboardCmd() tea.Cmd { + return func() tea.Msg { + text, err := clipboard.Read() + if err != nil { + return clipboardReadErrMsg{err: err} + } + return tea.PasteMsg{Content: text} + } +} + // Model is the Bubble Tea model for the block-based editor. type Model struct { blocks []block.Block // the data model @@ -116,6 +128,7 @@ type Model struct { cascadeChecks bool // checking parent also checks/unchecks children kanbanSortByPrio bool // sort kanban cards by priority desc within each column embedModal embedModalState // overlay for viewing embedded note references + linkPrompt linkPromptState // bottom-of-screen input for editing a link's URL or title embedPicker embedPicker // note picker for embed block insertion table *tableState // active table cell state (non-nil when editing a Table block) kanban *kanbanState // active kanban board state (non-nil when editing a Kanban block) @@ -265,6 +278,10 @@ func blockPrefixWidth(b block.Block) int { icon = "\u2197 " } base = lipgloss.Width(icon) + case block.Link: + // Link card prefixes the title with the link icon; editing happens + // in the bottom-sheet modal, not inline. + base = lipgloss.Width(linkIcon) case block.Table: base = 0 } @@ -1179,36 +1196,12 @@ func (m *Model) handleEnter() { } } - // Link: two-line internal structure (title on line 0, URL on line 1). - // Enter on the title line moves the cursor to the URL line. - // Enter on the URL line exits the block to a new paragraph below. + // Link: Enter opens the URL prompt. The textarea is just storage; edits + // happen in the bottom prompt, not inline. if bt == block.Link { - trimmed := strings.TrimSpace(strings.ReplaceAll(content, "\n", "")) - if trimmed == "" { - m.blocks[m.active].Type = block.Paragraph - m.blocks[m.active].Content = "" - newTA := newTextareaForBlock(m.blocks[m.active], m.width) - m.cursorCmd = newTA.Focus() - m.textareas[m.active] = newTA - return - } - // Enter at the very start of the title pushes the link down by - // inserting a new paragraph above. - if ta.Line() == 0 && ta.LineInfo().ColumnOffset == 0 { - m.insertBlockBefore(m.active, block.Block{Type: block.Paragraph}) - return - } - if ta.Line() == 0 { - lines := strings.Split(content, "\n") - if len(lines) < 2 { - ta.SetValue(content + "\n") - } - ta.CursorDown() - ta.CursorEnd() - m.cursorCmd = ta.Focus() - return - } - m.insertBlockAfter(m.active, block.Block{Type: block.Paragraph}) + ta.Blur() + _, url := block.ExtractLink(content) + m.linkPrompt.open(m.active, linkPromptURL, url, false) return } @@ -1360,6 +1353,17 @@ func (m *Model) handleBackspace() bool { return false } + bt := m.blocks[m.active].Type + + // Divider and Link cards are selected as a unit. Their textarea cursor is + // hidden storage, so deletion must not depend on its current position. + if bt == block.Divider || bt == block.Link { + m.pushUndo() + m.deleteBlock(m.active) + m.textareas[m.active].MoveToEnd() + return true + } + ta := &m.textareas[m.active] // Check if cursor is at position 0 (line 0, column 0). @@ -1372,7 +1376,6 @@ func (m *Model) handleBackspace() bool { } content := ta.Value() - bt := m.blocks[m.active].Type // Table: at cell (0,0) convert to paragraph; otherwise no-op. if bt == block.Table { @@ -1387,14 +1390,6 @@ func (m *Model) handleBackspace() bool { return true } - // Divider: delete the block. - if bt == block.Divider { - m.pushUndo() - m.deleteBlock(m.active) - m.textareas[m.active].MoveToEnd() - return true - } - // List items: outdent if indented, else convert to paragraph. if bt.IsListItem() { m.pushUndo() @@ -1422,12 +1417,6 @@ func (m *Model) handleBackspace() bool { case block.DefinitionList: // Keep only the term line. keepContent, _ = block.ExtractDefinition(content) - case block.Link: - title, url := block.ExtractLink(content) - keepContent = title - if keepContent == "" { - keepContent = url - } } m.convertToParagraph(keepContent) return true @@ -1580,10 +1569,16 @@ func (m *Model) applyPaletteSelection(bt block.BlockType) { m.textareas[m.active].MoveToBegin() m.cursorCmd = m.textareas[m.active].Focus() } - if bt == block.Link && !strings.Contains(m.textareas[m.active].Value(), "\n") { - m.textareas[m.active].SetValue("\n") - m.textareas[m.active].MoveToBegin() - m.cursorCmd = m.textareas[m.active].Focus() + if bt == block.Link { + if !strings.Contains(m.textareas[m.active].Value(), "\n") { + m.textareas[m.active].SetValue("\n") + m.textareas[m.active].MoveToBegin() + } + m.textareas[m.active].Blur() + // Sequential wizard: prompt for URL first; on commit, chain to title. + title, url := block.ExtractLink(m.textareas[m.active].Value()) + _ = title + m.linkPrompt.open(m.active, linkPromptURL, url, true) } // Open the note picker for embed blocks so the user can browse targets. if bt == block.Embed && m.config.ListEmbedTargets != nil { @@ -1733,6 +1728,11 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case clipboardReadErrMsg: + m.status = "Paste failed: " + msg.err.Error() + m.statusStyle = statusError + return m, m.scheduleStatusDismiss() + case tea.MouseMotionMsg: if m.embedModal.visible { contentStartY := m.embedModal.sheetStartY + 2 @@ -1859,17 +1859,23 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.blocks[idx].Type == block.Link { _, u := block.ExtractLink(m.blocks[idx].Content) - if u != "" { - if err := openURL(u); err == nil { - m.status = "Opened: " + u - m.statusStyle = statusSuccess - } else { - m.status = "Open failed: " + err.Error() - m.statusStyle = statusError + if u == "" { + // No URL yet — open the URL prompt so the user can fill it in. + if idx < len(m.textareas) { + m.textareas[idx].Blur() } - return m, m.scheduleStatusDismiss() + m.linkPrompt.open(idx, linkPromptURL, u, false) + m.updateViewport() + return m, nil } - return m, nil + if err := openURL(u); err == nil { + m.status = "Opened: " + u + m.statusStyle = statusSuccess + } else { + m.status = "Open failed: " + err.Error() + m.statusStyle = statusError + } + return m, m.scheduleStatusDismiss() } } } else { @@ -1902,8 +1908,17 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { // Bracketed-paste content. Route to whatever text widget is // currently accepting input so multi-line clipboards land in // one shot instead of being swallowed. - if m.viewMode || m.showHelp || m.quitPrompt || m.palette.Visible || - m.embedPicker.Visible || m.embedModal.visible || m.defLookup.Visible || + if m.linkPrompt.visible { + m.linkPrompt.input.InsertText(msg.Content) + m.updateViewport() + return m, nil + } + if picker := m.activePicker(); picker != nil { + picker.AddFilterText(msg.Content) + m.updateViewport() + return m, nil + } + if m.viewMode || m.showHelp || m.quitPrompt || m.embedModal.visible || m.defPreview.visible { return m, nil } @@ -1998,6 +2013,9 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { // Unified picker key handling: palette, embed picker, or def lookup. if picker := m.activePicker(); picker != nil { + if msg.String() == "ctrl+v" { + return m, readClipboardCmd() + } // Check trigger-key-closes before generic handling: // "/" re-typed closes palette, ":" re-typed closes deflookup. if m.palette.Visible && msg.Code == '/' { @@ -2028,6 +2046,62 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusStyle = statusNone } + // Link prompt: single-field input footer (URL or title). + if m.linkPrompt.visible { + key := msg.String() + switch { + case key == "ctrl+v": + return m, readClipboardCmd() + case key == "esc": + m.linkPrompt.close() + m.updateViewport() + return m, nil + case key == "ctrl+c": + m.linkPrompt.close() + if m.modified() { + m.quitPrompt = true + m.status = "Save before quitting? [Y/n/Esc]" + m.statusStyle = statusWarning + return m, nil + } + m.quitting = true + return m, tea.Quit + case key == "enter": + idx := m.linkPrompt.blockIdx + value := m.linkPrompt.input.Value() + field := m.linkPrompt.field + chain := m.linkPrompt.chain + m.linkPrompt.close() + if idx >= 0 && idx < len(m.blocks) { + title, url := block.ExtractLink(m.blocks[idx].Content) + if field == linkPromptURL { + url = value + } else { + title = value + } + m.pushUndo() + content := url + if title != "" { + content = url + "\n" + title + } + m.blocks[idx].Content = content + if idx < len(m.textareas) { + m.textareas[idx].SetValue(content) + m.textareas[idx].SetHeight(m.textareas[idx].VisualLineCount()) + } + if field == linkPromptURL && chain { + m.linkPrompt.open(idx, linkPromptTitle, title, false) + } + } + m.updateViewport() + return m, nil + } + if m.linkPrompt.input.HandleKey(msg) { + return m, nil + } + return m, nil + } + // Embed modal: intercept keys when overlay is showing. if m.embedModal.visible { switch msg.String() { @@ -2312,7 +2386,7 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { case "up": // Link blocks: up always navigates to the previous block, - // never swaps between title and URL slots. + // never swaps between URL and title slots. if m.blocks[m.active].Type == block.Link { if m.active == 0 { m.pushUndo() @@ -2352,7 +2426,7 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { case "down": // Link blocks: down always navigates to the next block, - // never swaps between title and URL slots. + // never swaps between URL and title slots. if m.blocks[m.active].Type == block.Link { if m.active < len(m.textareas)-1 { m.navigateDown() @@ -2589,22 +2663,17 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // On Link blocks, Tab/Shift+Tab jumps between title and URL slots. + // On Link blocks, Tab opens the bottom-sheet editor for URL + title. + // Tab → edit URL; Shift+Tab → edit title. if keyMsg.Code == tea.KeyTab && m.blocks[m.active].Type == block.Link { ta := &m.textareas[m.active] - content := ta.Value() - if !strings.Contains(content, "\n") { - ta.SetValue(content + "\n") - } + ta.Blur() + title, url := block.ExtractLink(ta.Value()) if keyMsg.Mod.Contains(tea.ModShift) { - ta.MoveToBegin() - ta.CursorEnd() + m.linkPrompt.open(m.active, linkPromptTitle, title, false) } else { - ta.MoveToBegin() - ta.CursorDown() - ta.CursorEnd() + m.linkPrompt.open(m.active, linkPromptURL, url, false) } - m.cursorCmd = ta.Focus() m.updateViewport() return m, nil } @@ -2865,6 +2934,13 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { msg = tea.KeyPressMsg{Code: tea.KeyBackspace} } } + if keyMsg.Code == tea.KeyDelete && (m.blocks[m.active].Type == block.Divider || m.blocks[m.active].Type == block.Link) { + m.pushUndo() + m.deleteBlock(m.active) + m.textareas[m.active].MoveToEnd() + m.updateViewport() + return m, nil + } // Divider: selected as a unit — no text input forwarded. // Enter and Backspace are handled above; everything else is ignored. @@ -2976,6 +3052,13 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textareas[m.active].SetHeight(m.textareas[m.active].VisualLineCount()) } + // Link blocks are edited via the bottom-sheet modal, so the textarea + // is just storage — don't forward keys to it (would otherwise let the + // user mutate the URL/title inline by typing while focused). + if m.active >= 0 && m.active < len(m.blocks) && m.blocks[m.active].Type == block.Link { + return m, nil + } + var cmd tea.Cmd m.textareas[m.active], cmd = m.textareas[m.active].Update(msg) @@ -3495,6 +3578,9 @@ func (m Model) renderPickerFooter() string { if m.defPreview.visible { return m.defPreview.RenderFooter(m.width) } + if m.linkPrompt.visible { + return m.renderLinkPrompt() + } return "" } @@ -3515,6 +3601,9 @@ func (m Model) footerHeight() int { if m.defPreview.visible { return m.defPreview.Height(m.width) } + if m.linkPrompt.visible { + return linkPromptHeight + } return 0 } @@ -3562,7 +3651,7 @@ func (m Model) blockHint() string { case block.Embed: return "\u2303X open \u00B7 Tab pick" case block.Link: - return "\u2303X open \u00B7 Tab url" + return "\u2303X open \u00B7 Tab url \u00B7 \u21E7Tab title" case block.DefinitionList: return "\u2303X search" default: diff --git a/internal/editor/link_modal.go b/internal/editor/link_modal.go new file mode 100644 index 0000000..f0515f5 --- /dev/null +++ b/internal/editor/link_modal.go @@ -0,0 +1,69 @@ +package editor + +import "github.com/oobagi/notebook-cli/internal/ui" + +// linkPromptField identifies which field a link prompt is editing. +type linkPromptField int + +const ( + linkPromptURL linkPromptField = iota + linkPromptTitle +) + +// linkPromptState is a single-line input footer for editing one field of a +// Link block. Two prompts run sequentially when inserting (URL then title); +// keybinds re-open one field at a time for edits. +type linkPromptState struct { + visible bool + field linkPromptField + chain bool // true when committing the URL should auto-open the title prompt + blockIdx int + input ui.TextInput +} + +func (s *linkPromptState) open(idx int, field linkPromptField, initial string, chain bool) { + s.visible = true + s.field = field + s.chain = chain + s.blockIdx = idx + s.input.SetValue(initial) +} + +func (s *linkPromptState) close() { + s.visible = false + s.field = linkPromptURL + s.chain = false + s.blockIdx = -1 + s.input.Reset() +} + +// label returns the prompt prefix for the current field. +func (s *linkPromptState) label() string { + if s.field == linkPromptTitle { + return "Title:" + } + return "URL:" +} + +// linkPromptHeight is the line count of the prompt footer (border + 1 line). +const linkPromptHeight = 2 + +// renderLinkPrompt draws a single-line input footer styled like the embed +// picker's filter row. +func (m Model) renderLinkPrompt() string { + w := m.width + if w <= 0 { + w = 80 + } + placeholder := "https://" + if m.linkPrompt.field == linkPromptTitle { + placeholder = "Link title" + } + return m.linkPrompt.input.RenderFooter(ui.TextInputProps{ + Prompt: m.linkPrompt.label(), + Placeholder: placeholder, + Hints: "Enter confirm · Esc cancel", + Width: w, + CursorVisible: true, + }) +} diff --git a/internal/editor/link_modal_test.go b/internal/editor/link_modal_test.go new file mode 100644 index 0000000..e95b795 --- /dev/null +++ b/internal/editor/link_modal_test.go @@ -0,0 +1,151 @@ +package editor + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/oobagi/notebook-cli/internal/block" +) + +func TestLinkPromptAcceptsBracketedPaste(t *testing.T) { + m := New(Config{Title: "test", Content: ""}) + m.blocks[0] = block.Block{Type: block.Link} + m.textareas[0] = newTextareaForBlock(m.blocks[0], m.width) + m.linkPrompt.open(0, linkPromptURL, "", false) + + updated, _ := m.Update(tea.PasteMsg{Content: "https://example.com\n"}) + m = updated.(Model) + + if got := m.linkPrompt.input.Value(); got != "https://example.com" { + t.Fatalf("prompt value = %q, want pasted URL", got) + } + + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + + if got := m.blocks[0].Content; got != "https://example.com" { + t.Fatalf("committed link content = %q, want pasted URL", got) + } +} + +func TestPickerAcceptsBracketedPaste(t *testing.T) { + m := New(Config{Title: "test", Content: ""}) + m.palette.open(0) + + updated, _ := m.Update(tea.PasteMsg{Content: "hea\n"}) + m = updated.(Model) + + if got := m.palette.Filter(); got != "hea" { + t.Fatalf("palette filter = %q, want pasted filter", got) + } +} + +func TestBackspaceDeletesSelectedLinkBlock(t *testing.T) { + m := New(Config{Title: "test", Content: "above\n\n[Example](https://example.com)\n\nbelow"}) + m.focusBlock(2) + m.textareas[m.active].MoveToEnd() + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + m = updated.(Model) + + if m.BlockCount() != 4 { + t.Fatalf("block count = %d, want link block removed", m.BlockCount()) + } + for _, b := range m.blocks { + if b.Type == block.Link { + t.Fatalf("link block was not deleted: %+v", m.blocks) + } + } + if m.active != 1 { + t.Fatalf("active block = %d, want previous block focused", m.active) + } +} + +func TestDeleteKeyDeletesSelectedLinkBlock(t *testing.T) { + m := New(Config{Title: "test", Content: "above\n\n[Example](https://example.com)\n\nbelow"}) + m.focusBlock(2) + m.textareas[m.active].MoveToEnd() + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDelete}) + m = updated.(Model) + + for _, b := range m.blocks { + if b.Type == block.Link { + t.Fatalf("link block was not deleted: %+v", m.blocks) + } + } +} + +func TestBackspaceDeletesOnlyLinkToEmptyParagraph(t *testing.T) { + m := New(Config{Title: "test", Content: "[Example](https://example.com)"}) + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + m = updated.(Model) + + if m.BlockCount() != 1 { + t.Fatalf("block count = %d, want one empty paragraph", m.BlockCount()) + } + if m.blocks[0].Type != block.Paragraph || m.blocks[0].Content != "" { + t.Fatalf("block = %+v, want empty paragraph", m.blocks[0]) + } +} + +func TestLinkPromptSupportsWordAndLineEditingKeys(t *testing.T) { + m := New(Config{Title: "test", Content: ""}) + m.linkPrompt.open(0, linkPromptURL, "alpha beta gamma", false) + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModAlt}) + m = updated.(Model) + if got := m.linkPrompt.input.Value(); got != "alpha beta " { + t.Fatalf("alt+backspace value = %q, want previous word deleted", got) + } + + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModAlt}) + m = updated.(Model) + if m.linkPrompt.input.Cursor() != len([]rune("alpha ")) { + t.Fatalf("alt+left cursor = %d, want start of previous word", m.linkPrompt.input.Cursor()) + } + + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyRight, Mod: tea.ModAlt}) + m = updated.(Model) + if m.linkPrompt.input.Cursor() != len([]rune("alpha beta")) { + t.Fatalf("alt+right cursor = %d, want end of next word", m.linkPrompt.input.Cursor()) + } + + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDelete, Mod: tea.ModAlt}) + m = updated.(Model) + if got := m.linkPrompt.input.Value(); got != "alpha beta" { + t.Fatalf("alt+delete value = %q, want next word deleted", got) + } +} + +func TestLinkPromptSupportsCommandStyleLineDeletes(t *testing.T) { + m := New(Config{Title: "test", Content: ""}) + m.linkPrompt.open(0, linkPromptURL, "prefix suffix", false) + m.linkPrompt.input.SetCursor(len([]rune("prefix"))) + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModSuper}) + m = updated.(Model) + if got := m.linkPrompt.input.Value(); got != " suffix" { + t.Fatalf("super+backspace value = %q, want text before cursor deleted", got) + } + if m.linkPrompt.input.Cursor() != 0 { + t.Fatalf("super+backspace cursor = %d, want 0", m.linkPrompt.input.Cursor()) + } + + m.linkPrompt.open(0, linkPromptURL, "prefix suffix", false) + m.linkPrompt.input.SetCursor(len([]rune("prefix"))) + updated, _ = m.Update(tea.KeyPressMsg{Code: 'k', Mod: tea.ModCtrl}) + m = updated.(Model) + if got := m.linkPrompt.input.Value(); got != "prefix" { + t.Fatalf("ctrl+k value = %q, want text after cursor deleted", got) + } + + m.linkPrompt.open(0, linkPromptURL, "prefix suffix", false) + m.linkPrompt.input.SetCursor(len([]rune("prefix"))) + updated, _ = m.Update(tea.KeyPressMsg{Code: 'u', Mod: tea.ModCtrl}) + m = updated.(Model) + if got := m.linkPrompt.input.Value(); got != " suffix" { + t.Fatalf("ctrl+u value = %q, want text before cursor deleted", got) + } +} diff --git a/internal/editor/link_render_test.go b/internal/editor/link_render_test.go new file mode 100644 index 0000000..8b95929 --- /dev/null +++ b/internal/editor/link_render_test.go @@ -0,0 +1,66 @@ +package editor + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/oobagi/notebook-cli/internal/block" +) + +// stripANSI returns the visible text with ANSI escapes removed so width +// assertions are stable across themes. +func stripANSI(s string) string { + return ansi.Strip(s) +} + +func TestRenderLinkCardWrapsLongTitleWhenWordWrapOn(t *testing.T) { + content := "https://example.com/path\nA really quite long bookmark title that will not fit on one line" + width := 30 + // Rendered card width = icon (2) + width. + maxWidth := width + lipgloss.Width("↗ ") + + out := renderLinkCard(content, width, false, true) + t.Logf("rendered:\n%s\n--- plain ---\n%s", out, stripANSI(out)) + + lines := strings.Split(stripANSI(out), "\n") + if len(lines) < 2 { + t.Fatalf("expected wrapped output across multiple lines, got %d:\n%s", len(lines), out) + } + for i, l := range lines { + if w := lipgloss.Width(l); w > maxWidth { + t.Errorf("line %d width %d exceeds max %d: %q", i, w, maxWidth, l) + } + } +} + +func TestRenderInactiveLinkBlockWrapsWithGutter(t *testing.T) { + b := block.Block{Type: block.Link, Content: "https://example.com/path\nA really quite long bookmark title that will not fit on one line"} + const totalWidth = 40 + out := renderInactiveBlock(b, b.Content, totalWidth, true, []block.Block{b}, 0) + t.Logf("rendered:\n%s\n--- plain ---\n%s", out, stripANSI(out)) + plainLines := strings.Split(stripANSI(out), "\n") + if len(plainLines) < 2 { + t.Fatalf("expected wrapped output across multiple lines, got %d:\n%s", len(plainLines), stripANSI(out)) + } + for i, l := range plainLines { + if w := lipgloss.Width(l); w > totalWidth { + t.Errorf("line %d width %d exceeds total width %d: %q", i, w, totalWidth, l) + } + } +} + +func TestRenderLinkCardTruncatesWhenWordWrapOff(t *testing.T) { + content := "https://example.com/path\nA really quite long bookmark title that will not fit on one line" + width := 30 + + out := renderLinkCard(content, width, false, false) + + if strings.Contains(out, "\n") { + t.Fatalf("expected single-line output when wordWrap is off, got:\n%s", out) + } + if !strings.Contains(stripANSI(out), "…") { + t.Errorf("expected ellipsis truncation in no-wrap mode, got: %q", stripANSI(out)) + } +} diff --git a/internal/editor/render.go b/internal/editor/render.go index 83df2b8..1d13b50 100644 --- a/internal/editor/render.go +++ b/internal/editor/render.go @@ -427,21 +427,10 @@ func (m Model) renderActiveBlock(idx int, b block.Block, _ string) string { } case block.Link: - rawLines := strings.Split(ta.Value(), "\n") - title := "" - url := "" - if len(rawLines) > 0 { - title = rawLines[0] - } - if len(rawLines) > 1 { - url = rawLines[1] - } - cursorOnTitle := ta.Line() == 0 - col := ta.LineInfo().ColumnOffset - chip, vCol := renderLinkChipActive(title, url, cursorOnTitle, col) - rendered = chip - cursorVisIdx = 0 - cursorColInWrap = vCol - blockPrefixWidth(b) + // Link blocks are edited via the bottom-sheet modal, not inline. The + // active state just renders the styled card so focus is still visible + // (gutter label) but no inline cursor or form is shown. + rendered = renderLinkCard(content, contentWidth, true, m.wordWrap) case block.Callout: cs := th.Blocks.Callout @@ -490,12 +479,7 @@ func (m Model) renderActiveBlock(idx int, b block.Block, _ string) string { } // Truncate or horizontally scroll lines that exceed terminal width. - if b.Type == block.Link { - cursorCol := gutterWidth + blockPrefixWidth(b) + cursorColInWrap - for i, l := range lines { - lines[i] = scrollOrTruncate(l, m.width, cursorCol, i == cursorVisIdx) - } - } else if m.wordWrap { + if m.wordWrap { for i, l := range lines { if lipgloss.Width(l) > m.width { lines[i] = ansi.Truncate(l, m.width, "") @@ -539,7 +523,8 @@ func linkStyles() (icon, title, host lipgloss.Style) { const linkIcon = "↗ " const linkSep = " " -func renderLinkCard(content string, width int, hovered bool) string { + +func renderLinkCard(content string, width int, hovered, wordWrap bool) string { iconStyle, titleStyle, hostStyle := linkStyles() if hovered { titleStyle = titleStyle.Underline(true) @@ -563,12 +548,13 @@ func renderLinkCard(content string, width int, hovered bool) string { host = linkHost(url) } - iconW := lipgloss.Width(linkIcon) sepW := lipgloss.Width(linkSep) hostW := lipgloss.Width(host) + // width is the title's available space; the icon is added externally + // via blockPrefixWidth so the gutter math lines up. if width > 0 { - avail := width - iconW + avail := width if host != "" { avail -= sepW + hostW } @@ -577,6 +563,9 @@ func renderLinkCard(content string, width int, hovered bool) string { host = "" } if lipgloss.Width(display) > avail { + if wordWrap { + return renderLinkCardWrapped(display, host, url, width, iconStyle, titleStyle, hostStyle) + } display = ansi.Truncate(display, avail, "…") } } @@ -593,56 +582,45 @@ func renderLinkCard(content string, width int, hovered bool) string { return out } -// renderLinkChipActive renders the link chip while editing. It returns the -// chip text and the visual cursor column (relative to the start of the -// rendered chip, including the icon) so the surrounding scroll math can -// keep the cursor in view. -func renderLinkChipActive(title, url string, cursorOnTitle bool, cursorCol int) (string, int) { - iconStyle, titleStyle, hostStyle := linkStyles() - urlEditStyle := hostStyle.Underline(false) - - var titleSlot string - var titleSlotPlain string - if title == "" { - titleSlot = renderPlaceholder("Link title", cursorOnTitle) - titleSlotPlain = "Link title" - } else if cursorOnTitle { - titleSlot = renderLabelCursor(title, cursorCol, titleStyle) - titleSlotPlain = title - } else { - titleSlot = titleStyle.Render(title) - titleSlotPlain = title - } +// renderLinkCardWrapped wraps an overflowing title across multiple lines, +// indenting continuation lines under the title (past the icon). The host +// is appended to the last title line if it fits, otherwise to its own line. +func renderLinkCardWrapped(display, host, url string, width int, iconStyle, titleStyle, hostStyle lipgloss.Style) string { + iconW := lipgloss.Width(linkIcon) + sepW := lipgloss.Width(linkSep) + hostW := lipgloss.Width(host) - var urlSlot string - if url == "" { - urlSlot = renderPlaceholder("https://", !cursorOnTitle) - } else if !cursorOnTitle { - urlSlot = renderLabelCursor(url, cursorCol, urlEditStyle) - } else { - host := linkHost(url) - if host == "" { - host = url - } - urlSlot = hostStyle.Render(host) + titleAvail := width + if titleAvail < 1 { + titleAvail = 1 } + titleLines := strings.Split(wrapText(display, titleAvail), "\n") + indent := strings.Repeat(" ", iconW) - chip := iconStyle.Render(linkIcon) + titleSlot + linkSep + urlSlot - - iconW := lipgloss.Width(linkIcon) - titleW := lipgloss.Width(titleSlotPlain) - if title == "" { - titleW = lipgloss.Width("Link title") + lines := make([]string, 0, len(titleLines)+1) + for i, l := range titleLines { + rl := titleStyle.Render(l) + if url != "" { + rl = osc8Wrap(url, rl) + } + if i == 0 { + lines = append(lines, iconStyle.Render(linkIcon)+rl) + } else { + lines = append(lines, indent+rl) + } } - sepW := lipgloss.Width(linkSep) - var vCol int - if cursorOnTitle { - vCol = iconW + cursorCol - } else { - vCol = iconW + titleW + sepW + cursorCol + if host != "" { + last := lines[len(lines)-1] + // Total visible width = icon/indent (iconW) + title text; we have + // iconW + width total to play with. + if lipgloss.Width(last)+sepW+hostW <= iconW+width { + lines[len(lines)-1] = last + linkSep + hostStyle.Render(host) + } else { + lines = append(lines, indent+hostStyle.Render(host)) + } } - return chip, vCol + return strings.Join(lines, "\n") } func linkHost(raw string) string { @@ -1052,7 +1030,7 @@ func renderInactiveBlock(b block.Block, content string, width int, wordWrap bool } case block.Link: - rendered = renderLinkCard(content, contentWidth, false) + rendered = renderLinkCard(content, contentWidth, false, wordWrap) case block.Table: tableWidth := width - gutterWidth @@ -1279,7 +1257,7 @@ func renderViewBlock(b block.Block, content string, width int, wordWrap bool, bl } case block.Link: - rendered = renderLinkCard(content, contentWidth, hovered) + rendered = renderLinkCard(content, contentWidth, hovered, true) case block.Table: rendered = renderTableGrid(content, contentWidth, th.Border, th.Blocks.Table.HeaderBold, true) diff --git a/internal/format/format.go b/internal/format/format.go index 01970ac..8c85bc5 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -33,15 +33,37 @@ func StatusBar(left, hint, right string, width int) string { // visible cursor, and right-side hints. Returns a fully styled, width-padded // string (faint text with a reverse-video cursor). func StatusBarInput(prompt, value string, cursorPos int, hints string, width int, cursorVisible bool) string { + return statusBarInput(prompt, value, "", cursorPos, hints, width, cursorVisible) +} + +// StatusBarInputWithPlaceholder renders StatusBarInput with placeholder text +// shown after the cursor when the value is empty. +func StatusBarInputWithPlaceholder(prompt, value, placeholder string, cursorPos int, hints string, width int, cursorVisible bool) string { + return statusBarInput(prompt, value, placeholder, cursorPos, hints, width, cursorVisible) +} + +func statusBarInput(prompt, value, placeholder string, cursorPos int, hints string, width int, cursorVisible bool) string { dim := lipgloss.NewStyle().Faint(true) rev := lipgloss.NewStyle().Reverse(true) - before := value[:cursorPos] + if cursorPos < 0 { + cursorPos = 0 + } + if cursorPos > len(value) { + cursorPos = len(value) + } + + runes := []rune(value) + if cursorPos > len(runes) { + cursorPos = len(runes) + } + + before := string(runes[:cursorPos]) cursorChar := " " after := "" - if cursorPos < len(value) { - cursorChar = string(value[cursorPos]) - after = value[cursorPos+1:] + if cursorPos < len(runes) { + cursorChar = string(runes[cursorPos]) + after = string(runes[cursorPos+1:]) } var cursor string @@ -52,6 +74,9 @@ func StatusBarInput(prompt, value string, cursorPos int, hints string, width int } left := dim.Render(" "+prompt+" "+before) + cursor + dim.Render(after) + if value == "" && placeholder != "" { + left += dim.Render(placeholder) + } right := dim.Render(hints) return StatusBar(left, "", right, width) } @@ -60,9 +85,15 @@ func StatusBarInput(prompt, value string, cursorPos int, hints string, width int // panel style used by editor pickers. This provides visual consistency between // browser input modes and editor picker footers. func FooterInput(prompt, value string, cursorPos int, hints string, width int, cursorVisible bool) string { + return FooterInputWithPlaceholder(prompt, value, "", cursorPos, hints, width, cursorVisible) +} + +// FooterInputWithPlaceholder renders FooterInput with placeholder text shown +// after the cursor when the value is empty. +func FooterInputWithPlaceholder(prompt, value, placeholder string, cursorPos int, hints string, width int, cursorVisible bool) string { muted := lipgloss.NewStyle().Faint(true) border := muted.Render(strings.Repeat("\u2500", width)) - input := StatusBarInput(prompt, value, cursorPos, hints, width, cursorVisible) + input := StatusBarInputWithPlaceholder(prompt, value, placeholder, cursorPos, hints, width, cursorVisible) return border + "\n" + input } diff --git a/internal/ui/picker.go b/internal/ui/picker.go index 97d3ef8..9d2d9bb 100644 --- a/internal/ui/picker.go +++ b/internal/ui/picker.go @@ -9,7 +9,7 @@ import ( // PickerItem is the interface that picker entries must satisfy. type PickerItem interface { - FilterValue() string // text matched against the filter + FilterValue() string // text matched against the filter RenderRow(selected bool, width int) string // render one row } @@ -90,6 +90,12 @@ func (p *Picker) AddFilterRune(r rune) { p.Refilter() } +// AddFilterText appends text to the filter and refilters. +func (p *Picker) AddFilterText(text string) { + p.filter += SanitizeSingleLinePaste(text) + p.Refilter() +} + // DeleteFilterRune removes the last rune. Returns false if the filter was // already empty, signalling the caller should close the picker. func (p *Picker) DeleteFilterRune() bool { diff --git a/internal/ui/text_input.go b/internal/ui/text_input.go new file mode 100644 index 0000000..e3a94ec --- /dev/null +++ b/internal/ui/text_input.go @@ -0,0 +1,236 @@ +package ui + +import ( + "strings" + "unicode" + + tea "charm.land/bubbletea/v2" + "github.com/oobagi/notebook-cli/internal/format" +) + +// TextInput is a reusable single-line input model for footer/status prompts. +type TextInput struct { + value []rune + cursor int +} + +// TextInputProps controls how a TextInput is rendered. +type TextInputProps struct { + Prompt string + Placeholder string + Hints string + Width int + CursorVisible bool +} + +func NewTextInput(value string) TextInput { + var t TextInput + t.SetValue(value) + return t +} + +func (t *TextInput) SetValue(value string) { + t.value = []rune(value) + t.cursor = len(t.value) +} + +func (t TextInput) Value() string { return string(t.value) } + +func (t TextInput) Cursor() int { return t.cursor } + +func (t *TextInput) SetCursor(cursor int) { + t.cursor = cursor + t.clamp() +} + +func (t *TextInput) Reset() { + t.value = nil + t.cursor = 0 +} + +func (t *TextInput) InsertText(text string) { + t.clamp() + runes := []rune(SanitizeSingleLinePaste(text)) + t.value = append(t.value[:t.cursor], append(runes, t.value[t.cursor:]...)...) + t.cursor += len(runes) +} + +// HandleKey processes common single-line editing keys. Mode-specific keys +// such as Enter, Esc, and paste commands stay with the caller. +func (t *TextInput) HandleKey(msg tea.KeyPressMsg) bool { + key := msg.String() + superOrMeta := msg.Mod.Contains(tea.ModSuper) || msg.Mod.Contains(tea.ModMeta) + switch { + case key == "left": + t.moveLeft() + case key == "right": + t.moveRight() + case key == "alt+left" || key == "alt+b": + t.moveWordLeft() + case key == "alt+right" || key == "alt+f": + t.moveWordRight() + case key == "home" || key == "ctrl+a" || key == "super+left" || key == "meta+left" || + (msg.Code == tea.KeyLeft && superOrMeta): + t.moveHome() + case key == "end" || key == "ctrl+e" || key == "super+right" || key == "meta+right" || + (msg.Code == tea.KeyRight && superOrMeta): + t.moveEnd() + case key == "backspace": + t.backspace() + case key == "delete": + t.deleteForward() + case key == "alt+backspace" || key == "ctrl+w": + t.deleteWordBackward() + case key == "alt+delete" || key == "alt+d": + t.deleteWordForward() + case key == "ctrl+u" || key == "super+backspace" || key == "meta+backspace" || + (msg.Code == tea.KeyBackspace && superOrMeta): + t.deleteBeforeCursor() + case key == "ctrl+k" || key == "super+delete" || key == "meta+delete" || + (msg.Code == tea.KeyDelete && superOrMeta): + t.deleteAfterCursor() + case key == "space": + t.InsertText(" ") + default: + if msg.Text == "" { + return false + } + t.InsertText(msg.Text) + } + return true +} + +func (t TextInput) RenderFooter(props TextInputProps) string { + return format.FooterInputWithPlaceholder(props.Prompt, t.Value(), props.Placeholder, t.cursor, props.Hints, props.Width, props.CursorVisible) +} + +func (t TextInput) RenderStatus(props TextInputProps) string { + return format.StatusBarInputWithPlaceholder(props.Prompt, t.Value(), props.Placeholder, t.cursor, props.Hints, props.Width, props.CursorVisible) +} + +// EditLine applies TextInput editing behavior to an existing string/cursor pair. +func EditLine(value string, cursor int, msg tea.KeyPressMsg) (string, int, bool) { + t := NewTextInput(value) + t.SetCursor(cursor) + handled := t.HandleKey(msg) + return t.Value(), t.Cursor(), handled +} + +// InsertLineText inserts pasted text into an existing string/cursor pair. +func InsertLineText(value string, cursor int, text string) (string, int) { + t := NewTextInput(value) + t.SetCursor(cursor) + t.InsertText(text) + return t.Value(), t.Cursor() +} + +// SanitizeSingleLinePaste makes pasted clipboard text suitable for one-line +// inputs while preserving normal typed text behavior. +func SanitizeSingleLinePaste(text string) string { + text = strings.Trim(text, "\r\n") + replacer := strings.NewReplacer( + "\r\n", " ", + "\n", " ", + "\r", " ", + "\t", " ", + ) + return replacer.Replace(text) +} + +func (t *TextInput) clamp() { + if t.cursor < 0 { + t.cursor = 0 + } + if t.cursor > len(t.value) { + t.cursor = len(t.value) + } +} + +func (t *TextInput) backspace() { + t.clamp() + if t.cursor == 0 { + return + } + t.value = append(t.value[:t.cursor-1], t.value[t.cursor:]...) + t.cursor-- +} + +func (t *TextInput) deleteForward() { + t.clamp() + if t.cursor >= len(t.value) { + return + } + t.value = append(t.value[:t.cursor], t.value[t.cursor+1:]...) +} + +func (t *TextInput) deleteBeforeCursor() { + t.clamp() + t.value = t.value[t.cursor:] + t.cursor = 0 +} + +func (t *TextInput) deleteAfterCursor() { + t.clamp() + t.value = t.value[:t.cursor] +} + +func (t *TextInput) deleteWordBackward() { + t.clamp() + if t.cursor == 0 { + return + } + start := t.wordLeft() + t.value = append(t.value[:start], t.value[t.cursor:]...) + t.cursor = start +} + +func (t *TextInput) deleteWordForward() { + t.clamp() + if t.cursor >= len(t.value) { + return + } + end := t.wordRight() + t.value = append(t.value[:t.cursor], t.value[end:]...) +} + +func (t *TextInput) moveLeft() { + if t.cursor > 0 { + t.cursor-- + } +} + +func (t *TextInput) moveRight() { + if t.cursor < len(t.value) { + t.cursor++ + } +} + +func (t *TextInput) moveHome() { t.cursor = 0 } +func (t *TextInput) moveEnd() { t.cursor = len(t.value) } + +func (t *TextInput) moveWordLeft() { t.cursor = t.wordLeft() } +func (t *TextInput) moveWordRight() { t.cursor = t.wordRight() } + +func (t *TextInput) wordLeft() int { + t.clamp() + pos := t.cursor + for pos > 0 && unicode.IsSpace(t.value[pos-1]) { + pos-- + } + for pos > 0 && !unicode.IsSpace(t.value[pos-1]) { + pos-- + } + return pos +} + +func (t *TextInput) wordRight() int { + t.clamp() + pos := t.cursor + for pos < len(t.value) && unicode.IsSpace(t.value[pos]) { + pos++ + } + for pos < len(t.value) && !unicode.IsSpace(t.value[pos]) { + pos++ + } + return pos +} diff --git a/internal/ui/text_input_test.go b/internal/ui/text_input_test.go new file mode 100644 index 0000000..01278ee --- /dev/null +++ b/internal/ui/text_input_test.go @@ -0,0 +1,60 @@ +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestTextInputEditingKeys(t *testing.T) { + input := NewTextInput("alpha beta gamma") + + input.HandleKey(tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModAlt}) + if got := input.Value(); got != "alpha beta " { + t.Fatalf("alt+backspace value = %q, want previous word deleted", got) + } + + input.HandleKey(tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModAlt}) + if got := input.Cursor(); got != len([]rune("alpha ")) { + t.Fatalf("alt+left cursor = %d, want start of previous word", got) + } + + input.HandleKey(tea.KeyPressMsg{Code: tea.KeyDelete, Mod: tea.ModAlt}) + if got := input.Value(); got != "alpha " { + t.Fatalf("alt+delete value = %q, want next word deleted", got) + } +} + +func TestTextInputCommandStyleLineDeletes(t *testing.T) { + input := NewTextInput("prefix suffix") + input.SetCursor(len([]rune("prefix"))) + + input.HandleKey(tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModSuper}) + if got := input.Value(); got != " suffix" { + t.Fatalf("super+backspace value = %q, want text before cursor deleted", got) + } + if got := input.Cursor(); got != 0 { + t.Fatalf("super+backspace cursor = %d, want 0", got) + } + + input.SetValue("prefix suffix") + input.SetCursor(len([]rune("prefix"))) + input.HandleKey(tea.KeyPressMsg{Code: 'k', Mod: tea.ModCtrl}) + if got := input.Value(); got != "prefix" { + t.Fatalf("ctrl+k value = %q, want text after cursor deleted", got) + } +} + +func TestTextInputPasteSanitizesToSingleLine(t *testing.T) { + input := NewTextInput("ab") + input.SetCursor(1) + + input.InsertText("x\ny\tz\n") + + if got := input.Value(); got != "ax y zb" { + t.Fatalf("value = %q, want sanitized paste inserted at cursor", got) + } + if got := input.Cursor(); got != len([]rune("ax y z")) { + t.Fatalf("cursor = %d, want after inserted text", got) + } +}