// Everything the Edit and Search menus do: the clipboard, undo, and moving // about the file. package app import ( "context" "fmt" "strconv" "strings" "codeberg.org/turbo-editors/turbo-core/buffer" "codeberg.org/turbo-editors/turbo-core/editor" "codeberg.org/turbo-editors/turbo-core/lsp" "codeberg.org/turbo-editors/turbo-core/ui" ) // Undo, Redo, Cut, Copy, Paste and SelectAll forward to the front window. func (a *App) Undo() { a.withView(func(v *editor.View) { v.Undo() }) } func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) } func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) } func (a *App) Copy() { a.withView(func(v *editor.View) { v.Copy() }) } func (a *App) Paste() { a.withView(func(v *editor.View) { v.Paste() }) } func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) } // InsertLine and DeleteLine are Turbo C's Ctrl-N and Ctrl-Y — a blank line // opened above the cursor, and the cursor's own line removed. func (a *App) InsertLine() { a.withView(func(v *editor.View) { v.InsertLine() }) } func (a *App) DeleteLine() { a.withView(func(v *editor.View) { v.DeleteLine() }) } // withView runs an action on the front window's view, if there is one. func (a *App) withView(action func(*editor.View)) { if view := a.activeView(); view != nil { action(view) } } // Find asks what to search for and jumps to the first match. func (a *App) Find() { view := a.activeView() if view == nil { return } dialog := NewFindDialog(a.lastSearch, a.lastMatchCase, a.screenRect()) a.pushModal(dialog.Dialog(), func(result ui.Result) { if result != ui.ResultOK { return } a.lastSearch, a.lastMatchCase = dialog.Needle(), dialog.MatchCase() a.FindNext() }) } // FindNext jumps to the next match of the last search. func (a *App) FindNext() { a.search(true) } // FindPrevious jumps to the previous match of the last search. func (a *App) FindPrevious() { a.search(false) } // search moves the cursor to the next or previous match and selects it. func (a *App) search(forwards bool) { view := a.activeView() if view == nil || a.lastSearch == "" { return } buf := view.Buffer() found, ok := a.nextMatch(buf, forwards) if !ok { a.Message(fmt.Sprintf("%q not found", a.lastSearch)) return } buf.SetCursor(found.Start) buf.StartSelection() buf.SetCursorKeepingSelection(found.End) view.EnsureCursorVisible() } // nextMatch finds the next or previous occurrence of the current search. // // A forward search starts one column past the cursor, so pressing "find next" // on a match moves off it instead of finding it again. func (a *App) nextMatch(buf *buffer.Buffer, forwards bool) (buffer.Range, bool) { from := buf.Cursor() if forwards { from.Col++ return buf.Find(a.lastSearch, from, a.lastMatchCase) } return buf.FindPrevious(a.lastSearch, from, a.lastMatchCase) } // GoToLine asks for a line number and jumps to it. func (a *App) GoToLine() { view := a.activeView() if view == nil { return } dialog, field := NewPromptDialog("Go to line", "Line number:", "", a.screenRect()) a.pushModal(dialog, func(result ui.Result) { if result != ui.ResultOK { return } line, err := strconv.Atoi(strings.TrimSpace(field.Text())) if err != nil || line < 1 { a.Message("Not a line number") return } view.GoToLine(line) }) } // GoToDefinition asks the language server where the symbol under the cursor is // declared, and opens it. // // A symbol may have more than one declaration — a Go interface method is // declared once per implementation — and then the list is offered rather than // the first one taken. It used to take locations[0] and throw the rest away, // which meant the editor silently answered a different question from the one // asked whenever the answer was interesting. func (a *App) GoToDefinition() { a.askForLocations("Definition", a.language.Definition) } // jumpTo opens the file a location names and puts the cursor on it. func (a *App) jumpTo(location lsp.Location) { path := lsp.URIToPath(location.URI) a.Open(path) view := a.activeView() if view == nil { return } line := location.Range.Start.Line column := lsp.UTF16ToRune(view.Buffer().Line(line), location.Range.Start.Character) view.Buffer().SetCursor(buffer.Position{Line: line, Col: column}) view.EnsureCursorVisible() } // DescribeSymbol shows what the language server knows about the symbol under // the cursor. func (a *App) DescribeSymbol() { view := a.activeView() if view == nil { a.ShowKeyboardHelp() return } buf := view.Buffer() cursor := buf.Cursor() text, err := a.language.Hover(context.Background(), buf.Path(), cursor.Line, cursor.Col, buf.Line(cursor.Line)) if err != nil || strings.TrimSpace(text) == "" { a.Message("Nothing to describe here") return } a.ShowMessage("Symbol", trimHover(text)) } // trimHover cuts a hover down to what fits in a small box: its first few // lines, with Markdown fences taken out. func trimHover(text string) string { const maxLines = 8 var kept []string for _, line := range strings.Split(text, "\n") { if strings.HasPrefix(line, "```") { continue } kept = append(kept, line) if len(kept) == maxLines { break } } return strings.TrimSpace(strings.Join(kept, "\n")) }