| 🛟 Updated. 28d5985 k33g 5h ago | 1 | // Everything the Edit and Search menus do: the clipboard, undo, and moving |
| 2 | // about the file. |
| 3 | package app |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | |
| 11 | "codeberg.org/turbo-editors/turbo-core/buffer" |
| 12 | "codeberg.org/turbo-editors/turbo-core/editor" |
| 13 | "codeberg.org/turbo-editors/turbo-core/lsp" |
| 14 | "codeberg.org/turbo-editors/turbo-core/ui" |
| 15 | ) |
| 16 | |
| 17 | // Undo, Redo, Cut, Copy, Paste and SelectAll forward to the front window. |
| 18 | func (a *App) Undo() { a.withView(func(v *editor.View) { v.Undo() }) } |
| 19 | func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) } |
| 20 | func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) } |
| 21 | func (a *App) Copy() { a.withView(func(v *editor.View) { v.Copy() }) } |
| 22 | func (a *App) Paste() { a.withView(func(v *editor.View) { v.Paste() }) } |
| 23 | func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) } |
| 24 | |
| 25 | // InsertLine and DeleteLine are Turbo C's Ctrl-N and Ctrl-Y — a blank line |
| 26 | // opened above the cursor, and the cursor's own line removed. |
| 27 | func (a *App) InsertLine() { a.withView(func(v *editor.View) { v.InsertLine() }) } |
| 28 | func (a *App) DeleteLine() { a.withView(func(v *editor.View) { v.DeleteLine() }) } |
| 29 | |
| 30 | // withView runs an action on the front window's view, if there is one. |
| 31 | func (a *App) withView(action func(*editor.View)) { |
| 32 | if view := a.activeView(); view != nil { |
| 33 | action(view) |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // Find asks what to search for and jumps to the first match. |
| 38 | func (a *App) Find() { |
| 39 | view := a.activeView() |
| 40 | if view == nil { |
| 41 | return |
| 42 | } |
| 43 | |
| 44 | dialog := NewFindDialog(a.lastSearch, a.lastMatchCase, a.screenRect()) |
| 45 | a.pushModal(dialog.Dialog(), func(result ui.Result) { |
| 46 | if result != ui.ResultOK { |
| 47 | return |
| 48 | } |
| 49 | a.lastSearch, a.lastMatchCase = dialog.Needle(), dialog.MatchCase() |
| 50 | a.FindNext() |
| 51 | }) |
| 52 | } |
| 53 | |
| 54 | // FindNext jumps to the next match of the last search. |
| 55 | func (a *App) FindNext() { a.search(true) } |
| 56 | |
| 57 | // FindPrevious jumps to the previous match of the last search. |
| 58 | func (a *App) FindPrevious() { a.search(false) } |
| 59 | |
| 60 | // search moves the cursor to the next or previous match and selects it. |
| 61 | func (a *App) search(forwards bool) { |
| 62 | view := a.activeView() |
| 63 | if view == nil || a.lastSearch == "" { |
| 64 | return |
| 65 | } |
| 66 | buf := view.Buffer() |
| 67 | |
| 68 | found, ok := a.nextMatch(buf, forwards) |
| 69 | if !ok { |
| 70 | a.Message(fmt.Sprintf("%q not found", a.lastSearch)) |
| 71 | return |
| 72 | } |
| 73 | |
| 74 | buf.SetCursor(found.Start) |
| 75 | buf.StartSelection() |
| 76 | buf.SetCursorKeepingSelection(found.End) |
| 77 | view.EnsureCursorVisible() |
| 78 | } |
| 79 | |
| 80 | // nextMatch finds the next or previous occurrence of the current search. |
| 81 | // |
| 82 | // A forward search starts one column past the cursor, so pressing "find next" |
| 83 | // on a match moves off it instead of finding it again. |
| 84 | func (a *App) nextMatch(buf *buffer.Buffer, forwards bool) (buffer.Range, bool) { |
| 85 | from := buf.Cursor() |
| 86 | if forwards { |
| 87 | from.Col++ |
| 88 | return buf.Find(a.lastSearch, from, a.lastMatchCase) |
| 89 | } |
| 90 | return buf.FindPrevious(a.lastSearch, from, a.lastMatchCase) |
| 91 | } |
| 92 | |
| 93 | // GoToLine asks for a line number and jumps to it. |
| 94 | func (a *App) GoToLine() { |
| 95 | view := a.activeView() |
| 96 | if view == nil { |
| 97 | return |
| 98 | } |
| 99 | |
| 100 | dialog, field := NewPromptDialog("Go to line", "Line number:", "", a.screenRect()) |
| 101 | a.pushModal(dialog, func(result ui.Result) { |
| 102 | if result != ui.ResultOK { |
| 103 | return |
| 104 | } |
| 105 | line, err := strconv.Atoi(strings.TrimSpace(field.Text())) |
| 106 | if err != nil || line < 1 { |
| 107 | a.Message("Not a line number") |
| 108 | return |
| 109 | } |
| 110 | view.GoToLine(line) |
| 111 | }) |
| 112 | } |
| 113 | |
| 114 | // GoToDefinition asks the language server where the symbol under the cursor is |
| 115 | // declared, and opens it. |
| 116 | // |
| 117 | // A symbol may have more than one declaration — a Go interface method is |
| 118 | // declared once per implementation — and then the list is offered rather than |
| 119 | // the first one taken. It used to take locations[0] and throw the rest away, |
| 120 | // which meant the editor silently answered a different question from the one |
| 121 | // asked whenever the answer was interesting. |
| 122 | func (a *App) GoToDefinition() { |
| 123 | a.askForLocations("Definition", a.language.Definition) |
| 124 | } |
| 125 | |
| 126 | // jumpTo opens the file a location names and puts the cursor on it. |
| 127 | func (a *App) jumpTo(location lsp.Location) { |
| 128 | path := lsp.URIToPath(location.URI) |
| 129 | a.Open(path) |
| 130 | |
| 131 | view := a.activeView() |
| 132 | if view == nil { |
| 133 | return |
| 134 | } |
| 135 | line := location.Range.Start.Line |
| 136 | column := lsp.UTF16ToRune(view.Buffer().Line(line), location.Range.Start.Character) |
| 137 | |
| 138 | view.Buffer().SetCursor(buffer.Position{Line: line, Col: column}) |
| 139 | view.EnsureCursorVisible() |
| 140 | } |
| 141 | |
| 142 | // DescribeSymbol shows what the language server knows about the symbol under |
| 143 | // the cursor. |
| 144 | func (a *App) DescribeSymbol() { |
| 145 | view := a.activeView() |
| 146 | if view == nil { |
| 147 | a.ShowKeyboardHelp() |
| 148 | return |
| 149 | } |
| 150 | buf := view.Buffer() |
| 151 | cursor := buf.Cursor() |
| 152 | |
| 153 | text, err := a.language.Hover(context.Background(), |
| 154 | buf.Path(), cursor.Line, cursor.Col, buf.Line(cursor.Line)) |
| 155 | if err != nil || strings.TrimSpace(text) == "" { |
| 156 | a.Message("Nothing to describe here") |
| 157 | return |
| 158 | } |
| 159 | a.ShowMessage("Symbol", trimHover(text)) |
| 160 | } |
| 161 | |
| 162 | // trimHover cuts a hover down to what fits in a small box: its first few |
| 163 | // lines, with Markdown fences taken out. |
| 164 | func trimHover(text string) string { |
| 165 | const maxLines = 8 |
| 166 | |
| 167 | var kept []string |
| 168 | for _, line := range strings.Split(text, "\n") { |
| 169 | if strings.HasPrefix(line, "```") { |
| 170 | continue |
| 171 | } |
| 172 | kept = append(kept, line) |
| 173 | if len(kept) == maxLines { |
| 174 | break |
| 175 | } |
| 176 | } |
| 177 | return strings.TrimSpace(strings.Join(kept, "\n")) |
| 178 | } |