diff options
-rw-r--r-- | runtime/doc/api.txt | 43 | ||||
-rw-r--r-- | runtime/doc/eval.txt | 11 | ||||
-rw-r--r-- | runtime/doc/if_lua.txt | 19 | ||||
-rw-r--r-- | runtime/doc/provider.txt | 35 | ||||
-rw-r--r-- | runtime/doc/term.txt | 6 | ||||
-rw-r--r-- | src/nvim/api/private/helpers.c | 29 | ||||
-rw-r--r-- | src/nvim/api/vim.c | 171 | ||||
-rw-r--r-- | src/nvim/event/loop.c | 13 | ||||
-rw-r--r-- | src/nvim/ex_getln.c | 2 | ||||
-rw-r--r-- | src/nvim/getchar.c | 29 | ||||
-rw-r--r-- | src/nvim/globals.h | 6 | ||||
-rw-r--r-- | src/nvim/keymap.c | 12 | ||||
-rw-r--r-- | src/nvim/keymap.h | 5 | ||||
-rw-r--r-- | src/nvim/lua/vim.lua | 78 | ||||
-rw-r--r-- | src/nvim/ops.c | 69 | ||||
-rw-r--r-- | src/nvim/os/input.c | 4 | ||||
-rw-r--r-- | src/nvim/state.c | 4 | ||||
-rw-r--r-- | src/nvim/terminal.c | 4 | ||||
-rw-r--r-- | src/nvim/tui/input.c | 94 | ||||
-rw-r--r-- | src/nvim/tui/input.h | 3 | ||||
-rw-r--r-- | src/nvim/tui/tui.c | 6 | ||||
-rw-r--r-- | src/nvim/types.h | 6 | ||||
-rw-r--r-- | test/functional/api/vim_spec.lua | 129 | ||||
-rw-r--r-- | test/functional/terminal/helpers.lua | 3 | ||||
-rw-r--r-- | test/functional/terminal/tui_spec.lua | 366 | ||||
-rw-r--r-- | test/functional/ui/input_spec.lua | 71 |
26 files changed, 996 insertions, 222 deletions
diff --git a/runtime/doc/api.txt b/runtime/doc/api.txt index 2c6b053994..32d7f5eb1e 100644 --- a/runtime/doc/api.txt +++ b/runtime/doc/api.txt @@ -793,6 +793,49 @@ nvim_get_namespaces() *nvim_get_namespaces()* Return: ~ dict that maps from names to namespace ids. +nvim_paste({data}, {phase}) *nvim_paste()* + Pastes at cursor, in any mode. + + Invokes the `vim.paste` handler, which handles each mode + appropriately. Sets redo/undo. Faster than |nvim_input()|. + + Errors ('nomodifiable', `vim.paste()` failure, …) are + reflected in `err` but do not affect the return value (which + is strictly decided by `vim.paste()` ). On error, subsequent + calls are ignored ("drained") until the next paste is + initiated (phase 1 or -1). + + Parameters: ~ + {data} Multiline input. May be binary (containing NUL + bytes). + {phase} -1: paste in a single call (i.e. without + streaming). To "stream" a paste, call `nvim_paste` sequentially with these `phase` values: + • 1: starts the paste (exactly once) + • 2: continues the paste (zero or more times) + • 3: ends the paste (exactly once) + + Return: ~ + + • true: Client may continue pasting. + • false: Client must cancel the paste. + +nvim_put({lines}, {type}, {after}, {follow}) *nvim_put()* + Puts text at cursor, in any mode. + + Compare |:put| and |p| which are always linewise. + + Parameters: ~ + {lines} |readfile()|-style list of lines. + |channel-lines| + {type} Edit behavior: + • "b" |blockwise-visual| mode + • "c" |characterwise| mode + • "l" |linewise| mode + • "" guess by contents + {after} Insert after cursor (like |p|), or before (like + |P|). + {follow} Place cursor at end of inserted text. + nvim_subscribe({event}) *nvim_subscribe()* Subscribes to event broadcasts. diff --git a/runtime/doc/eval.txt b/runtime/doc/eval.txt index 5d30ac15b3..897b5df072 100644 --- a/runtime/doc/eval.txt +++ b/runtime/doc/eval.txt @@ -4214,17 +4214,6 @@ getchar([expr]) *getchar()* : endwhile :endfunction < - You may also receive synthetic characters, such as - |<CursorHold>|. Often you will want to ignore this and get - another character: > - :function GetKey() - : let c = getchar() - : while c == "\<CursorHold>" - : let c = getchar() - : endwhile - : return c - :endfunction - getcharmod() *getcharmod()* The result is a Number which is the state of the modifiers for the last obtained character with getchar() or in another way. diff --git a/runtime/doc/if_lua.txt b/runtime/doc/if_lua.txt index a9b8c5fae8..1837e14623 100644 --- a/runtime/doc/if_lua.txt +++ b/runtime/doc/if_lua.txt @@ -533,6 +533,25 @@ inspect({object}, {options}) *vim.inspect()* See also: ~ https://github.com/kikito/inspect.lua +paste({lines}, {phase}) *vim.paste()* + Paste handler, invoked by |nvim_paste()| when a conforming UI + (such as the |TUI|) pastes text into the editor. + + Parameters: ~ + {lines} |readfile()|-style list of lines to paste. + |channel-lines| + {phase} -1: "non-streaming" paste: the call contains all + lines. If paste is "streamed", `phase` indicates the stream state: + • 1: starts the paste (exactly once) + • 2: continues the paste (zero or more times) + • 3: ends the paste (exactly once) + + Return: ~ + false if client should cancel the paste. + + See also: ~ + |paste| + diff --git a/runtime/doc/provider.txt b/runtime/doc/provider.txt index dc045c360a..833be8a103 100644 --- a/runtime/doc/provider.txt +++ b/runtime/doc/provider.txt @@ -219,6 +219,41 @@ function returns the clipboard as a `[lines, regtype]` list, where `lines` is a list of lines and `regtype` is a register type conforming to |setreg()|. ============================================================================== +Paste *provider-paste* *paste* + +"Paste" is a separate concept from |clipboard|: paste means "dump a bunch of +text to the editor", whereas clipboard provides features like |quote-+| to get +and set the OS clipboard directly. For example, middle-click or CTRL-SHIFT-v +(macOS: CMD-v) in your terminal is "paste", not "clipboard": the terminal +application (Nvim) just gets a stream of text, it does not interact with the +clipboard directly. + + *bracketed-paste-mode* +Pasting in the |TUI| depends on the "bracketed paste" terminal capability, +which allows terminal applications to distinguish between user input and +pasted text. https://cirw.in/blog/bracketed-paste +This works automatically if your terminal supports it. + + *ui-paste* +GUIs can paste by calling |nvim_paste()|. + +PASTE BEHAVIOR ~ + +Paste always inserts text after the cursor. In cmdline-mode only the first +line is pasted, to avoid accidentally executing many commands. Use the +|cmdline-window| if you really want to paste multiple lines to the cmdline. + +When pasting a huge amount of text, screen updates are throttled and the +message area shows a "..." pulse. + +You can implement a custom paste handler by redefining |vim.paste()|. +Example: > + + vim.paste = (function(lines, phase) + vim.api.nvim_put(lines, 'c', true, true) + end) + +============================================================================== X11 selection mechanism *clipboard-x11* *x11-selection* X11 clipboard providers store text in "selections". Selections are owned by an diff --git a/runtime/doc/term.txt b/runtime/doc/term.txt index 978f50dd55..4f4d379f01 100644 --- a/runtime/doc/term.txt +++ b/runtime/doc/term.txt @@ -219,12 +219,6 @@ effect on some UIs. ============================================================================== Using the mouse *mouse-using* - *bracketed-paste-mode* -Nvim enables bracketed paste by default. Bracketed paste mode allows terminal -applications to distinguish between typed text and pasted text. Thus you can -paste text without Nvim trying to format or indent the text. -See also https://cirw.in/blog/bracketed-paste - *mouse-mode-table* *mouse-overview* Overview of what the mouse buttons do, when 'mousemodel' is "extend": diff --git a/src/nvim/api/private/helpers.c b/src/nvim/api/private/helpers.c index 6b05d1ac0a..3443f85e20 100644 --- a/src/nvim/api/private/helpers.c +++ b/src/nvim/api/private/helpers.c @@ -745,6 +745,35 @@ String ga_take_string(garray_T *ga) return str; } +/// Creates "readfile()-style" ArrayOf(String). +/// +/// - NUL bytes are replaced with NL (form-feed). +/// - If last line ends with NL an extra empty list item is added. +Array string_to_array(const String input) +{ + Array ret = ARRAY_DICT_INIT; + for (size_t i = 0; i < input.size; i++) { + const char *start = input.data + i; + const char *end = xmemscan(start, NL, input.size - i); + const size_t line_len = (size_t)(end - start); + i += line_len; + + String s = { + .size = line_len, + .data = xmemdupz(start, line_len), + }; + memchrsub(s.data, NUL, NL, line_len); + ADD(ret, STRING_OBJ(s)); + // If line ends at end-of-buffer, add empty final item. + // This is "readfile()-style", see also ":help channel-lines". + if (i + 1 == input.size && end[0] == NL) { + ADD(ret, STRING_OBJ(cchar_to_string(NUL))); + } + } + + return ret; +} + /// Set, tweak, or remove a mapping in a mode. Acts as the implementation for /// functions like @ref nvim_buf_set_keymap. /// diff --git a/src/nvim/api/vim.c b/src/nvim/api/vim.c index d027eca59a..1ca0d8789d 100644 --- a/src/nvim/api/vim.c +++ b/src/nvim/api/vim.c @@ -29,6 +29,7 @@ #include "nvim/ex_docmd.h" #include "nvim/screen.h" #include "nvim/memline.h" +#include "nvim/mark.h" #include "nvim/memory.h" #include "nvim/message.h" #include "nvim/popupmnu.h" @@ -36,6 +37,7 @@ #include "nvim/eval.h" #include "nvim/eval/typval.h" #include "nvim/fileio.h" +#include "nvim/ops.h" #include "nvim/option.h" #include "nvim/state.h" #include "nvim/syntax.h" @@ -52,6 +54,20 @@ # include "api/vim.c.generated.h" #endif +// `msg_list` controls the collection of abort-causing non-exception errors, +// which would otherwise be ignored. This pattern is from do_cmdline(). +// +// TODO(bfredl): prepare error-handling at "top level" (nv_event). +#define TRY_WRAP(code) \ + do { \ + struct msglist **saved_msg_list = msg_list; \ + struct msglist *private_msg_list; \ + msg_list = &private_msg_list; \ + private_msg_list = NULL; \ + code \ + msg_list = saved_msg_list; /* Restore the exception context. */ \ + } while (0) + void api_vim_init(void) FUNC_API_NOEXPORT { @@ -390,13 +406,7 @@ Object nvim_eval(String expr, Error *err) static int recursive = 0; // recursion depth Object rv = OBJECT_INIT; - // `msg_list` controls the collection of abort-causing non-exception errors, - // which would otherwise be ignored. This pattern is from do_cmdline(). - struct msglist **saved_msg_list = msg_list; - struct msglist *private_msg_list; - msg_list = &private_msg_list; - private_msg_list = NULL; - + TRY_WRAP({ // Initialize `force_abort` and `suppress_errthrow` at the top level. if (!recursive) { force_abort = false; @@ -421,8 +431,8 @@ Object nvim_eval(String expr, Error *err) } tv_clear(&rettv); - msg_list = saved_msg_list; // Restore the exception context. recursive--; + }); return rv; } @@ -472,13 +482,7 @@ static Object _call_function(String fn, Array args, dict_T *self, Error *err) } } - // `msg_list` controls the collection of abort-causing non-exception errors, - // which would otherwise be ignored. This pattern is from do_cmdline(). - struct msglist **saved_msg_list = msg_list; - struct msglist *private_msg_list; - msg_list = &private_msg_list; - private_msg_list = NULL; - + TRY_WRAP({ // Initialize `force_abort` and `suppress_errthrow` at the top level. if (!recursive) { force_abort = false; @@ -500,8 +504,8 @@ static Object _call_function(String fn, Array args, dict_T *self, Error *err) rv = vim_to_object(&rettv); } tv_clear(&rettv); - msg_list = saved_msg_list; // Restore the exception context. recursive--; + }); free_vim_args: while (i > 0) { @@ -1204,6 +1208,141 @@ Dictionary nvim_get_namespaces(void) return retval; } +/// Pastes at cursor, in any mode. +/// +/// Invokes the `vim.paste` handler, which handles each mode appropriately. +/// Sets redo/undo. Faster than |nvim_input()|. +/// +/// Errors ('nomodifiable', `vim.paste()` failure, …) are reflected in `err` +/// but do not affect the return value (which is strictly decided by +/// `vim.paste()`). On error, subsequent calls are ignored ("drained") until +/// the next paste is initiated (phase 1 or -1). +/// +/// @param data Multiline input. May be binary (containing NUL bytes). +/// @param phase -1: paste in a single call (i.e. without streaming). +/// To "stream" a paste, call `nvim_paste` sequentially with +/// these `phase` values: +/// - 1: starts the paste (exactly once) +/// - 2: continues the paste (zero or more times) +/// - 3: ends the paste (exactly once) +/// @param[out] err Error details, if any +/// @return +/// - true: Client may continue pasting. +/// - false: Client must cancel the paste. +Boolean nvim_paste(String data, Integer phase, Error *err) + FUNC_API_SINCE(6) +{ + static bool draining = false; + bool cancel = false; + + if (phase < -1 || phase > 3) { + api_set_error(err, kErrorTypeValidation, "Invalid phase: %"PRId64, phase); + return false; + } + Array args = ARRAY_DICT_INIT; + Object rv = OBJECT_INIT; + if (phase == -1 || phase == 1) { // Start of paste-stream. + draining = false; + } else if (draining) { + // Skip remaining chunks. Report error only once per "stream". + goto theend; + } + Array lines = string_to_array(data); + ADD(args, ARRAY_OBJ(lines)); + ADD(args, INTEGER_OBJ(phase)); + rv = nvim_execute_lua(STATIC_CSTR_AS_STRING("return vim.paste(...)"), args, + err); + if (ERROR_SET(err)) { + draining = true; + goto theend; + } + if (!(State & CMDLINE) && !(State & INSERT) && (phase == -1 || phase == 1)) { + ResetRedobuff(); + AppendCharToRedobuff('a'); // Dot-repeat. + } + // vim.paste() decides if client should cancel. Errors do NOT cancel: we + // want to drain remaining chunks (rather than divert them to main input). + cancel = (rv.type == kObjectTypeBoolean && !rv.data.boolean); + if (!cancel && !(State & CMDLINE)) { // Dot-repeat. + for (size_t i = 0; i < lines.size; i++) { + String s = lines.items[i].data.string; + assert(data.size <= INT_MAX); + AppendToRedobuffLit((char_u *)s.data, (int)s.size); + // readfile()-style: "\n" is indicated by presence of N+1 item. + if (i + 1 < lines.size) { + AppendCharToRedobuff(NL); + } + } + } + if (!(State & CMDLINE) && !(State & INSERT) && (phase == -1 || phase == 3)) { + AppendCharToRedobuff(ESC); // Dot-repeat. + } +theend: + api_free_object(rv); + api_free_array(args); + if (cancel || phase == -1 || phase == 3) { // End of paste-stream. + draining = false; + // XXX: Tickle main loop to ensure cursor is updated. + loop_schedule_deferred(&main_loop, event_create(loop_dummy_event, 0)); + } + + return !cancel; +} + +/// Puts text at cursor, in any mode. +/// +/// Compare |:put| and |p| which are always linewise. +/// +/// @param lines |readfile()|-style list of lines. |channel-lines| +/// @param type Edit behavior: +/// - "b" |blockwise-visual| mode +/// - "c" |characterwise| mode +/// - "l" |linewise| mode +/// - "" guess by contents +/// @param after Insert after cursor (like |p|), or before (like |P|). +/// @param follow Place cursor at end of inserted text. +/// @param[out] err Error details, if any +void nvim_put(ArrayOf(String) lines, String type, Boolean after, + Boolean follow, Error *err) + FUNC_API_SINCE(6) +{ + yankreg_T *reg = xcalloc(sizeof(yankreg_T), 1); + if (!prepare_yankreg_from_object(reg, type, lines.size)) { + api_set_error(err, kErrorTypeValidation, "Invalid type: '%s'", type.data); + goto cleanup; + } + if (lines.size == 0) { + goto cleanup; // Nothing to do. + } + + for (size_t i = 0; i < lines.size; i++) { + if (lines.items[i].type != kObjectTypeString) { + api_set_error(err, kErrorTypeValidation, + "Invalid lines (expected array of strings)"); + goto cleanup; + } + String line = lines.items[i].data.string; + reg->y_array[i] = (char_u *)xmemdupz(line.data, line.size); + memchrsub(reg->y_array[i], NUL, NL, line.size); + } + + finish_yankreg_from_object(reg, false); + + TRY_WRAP({ + try_start(); + bool VIsual_was_active = VIsual_active; + msg_silent++; // Avoid "N more lines" message. + do_put(0, reg, after ? FORWARD : BACKWARD, 1, follow ? PUT_CURSEND : 0); + msg_silent--; + VIsual_active = VIsual_was_active; + try_end(err); + }); + +cleanup: + free_register(reg); + xfree(reg); +} + /// Subscribes to event broadcasts. /// /// @param channel_id Channel id (passed automatically by the dispatcher) diff --git a/src/nvim/event/loop.c b/src/nvim/event/loop.c index 609c723c57..529ddd8eba 100644 --- a/src/nvim/event/loop.c +++ b/src/nvim/event/loop.c @@ -36,6 +36,10 @@ void loop_init(Loop *loop, void *data) /// Processes all `Loop.fast_events` events. /// Does NOT process `Loop.events`, that is an application-specific decision. /// +/// @param loop +/// @param ms 0: non-blocking poll. +/// >0: timeout after `ms`. +/// <0: wait forever. /// @returns true if `ms` timeout was reached bool loop_poll_events(Loop *loop, int ms) { @@ -104,10 +108,10 @@ static void loop_deferred_event(void **argv) void loop_on_put(MultiQueue *queue, void *data) { Loop *loop = data; - // Sometimes libuv will run pending callbacks(timer for example) before + // Sometimes libuv will run pending callbacks (timer for example) before // blocking for a poll. If this happens and the callback pushes a event to one // of the queues, the event would only be processed after the poll - // returns(user hits a key for example). To avoid this scenario, we call + // returns (user hits a key for example). To avoid this scenario, we call // uv_stop when a event is enqueued. uv_stop(&loop->uv); } @@ -158,10 +162,15 @@ size_t loop_size(Loop *loop) return rv; } +void loop_dummy_event(void **argv) +{ +} + static void async_cb(uv_async_t *handle) { Loop *l = handle->loop->data; uv_mutex_lock(&l->mutex); + // Flush thread_events to fast_events for processing on main loop. while (!multiqueue_empty(l->thread_events)) { Event ev = multiqueue_get(l->thread_events); multiqueue_put_event(l->fast_events, ev); diff --git a/src/nvim/ex_getln.c b/src/nvim/ex_getln.c index e8d650accf..2b01e2d72b 100644 --- a/src/nvim/ex_getln.c +++ b/src/nvim/ex_getln.c @@ -532,7 +532,7 @@ static int command_line_check(VimState *state) static int command_line_execute(VimState *state, int key) { - if (key == K_IGNORE || key == K_PASTE) { + if (key == K_IGNORE) { return -1; // get another key } diff --git a/src/nvim/getchar.c b/src/nvim/getchar.c index 03f64c2019..0ef0c852a4 100644 --- a/src/nvim/getchar.c +++ b/src/nvim/getchar.c @@ -151,7 +151,6 @@ static char_u typebuf_init[TYPELEN_INIT]; /* initial typebuf.tb_buf */ static char_u noremapbuf_init[TYPELEN_INIT]; /* initial typebuf.tb_noremap */ static size_t last_recorded_len = 0; // number of last recorded chars -static const uint8_t ui_toggle[] = { K_SPECIAL, KS_EXTRA, KE_PASTE, 0 }; #ifdef INCLUDE_GENERATED_DECLARATIONS # include "getchar.c.generated.h" @@ -524,15 +523,12 @@ void AppendToRedobuff(const char *s) } } -/* - * Append to Redo buffer literally, escaping special characters with CTRL-V. - * K_SPECIAL and CSI are escaped as well. - */ -void -AppendToRedobuffLit ( - char_u *str, - int len /* length of "str" or -1 for up to the NUL */ -) +/// Append to Redo buffer literally, escaping special characters with CTRL-V. +/// K_SPECIAL and CSI are escaped as well. +/// +/// @param str String to append +/// @param len Length of `str` or -1 for up to the NUL. +void AppendToRedobuffLit(const char_u *str, int len) { if (block_redo) { return; @@ -1902,14 +1898,8 @@ static int vgetorpeek(int advance) } } - // Check for a key that can toggle the 'paste' option - if (mp == NULL && (State & (INSERT|NORMAL))) { - bool match = typebuf_match_len(ui_toggle, &mlen); - if (!match && mlen != typebuf.tb_len && *p_pt != NUL) { - // didn't match ui_toggle_key and didn't try the whole typebuf, - // check the 'pastetoggle' - match = typebuf_match_len(p_pt, &mlen); - } + if (*p_pt != NUL && mp == NULL && (State & (INSERT|NORMAL))) { + bool match = typebuf_match_len(p_pt, &mlen); if (match) { // write chars to script file(s) if (mlen > typebuf.tb_maplen) { @@ -1940,8 +1930,7 @@ static int vgetorpeek(int advance) } if ((mp == NULL || max_mlen >= mp_match_len) - && keylen != KEYLEN_PART_MAP - && !(keylen == KEYLEN_PART_KEY && c1 == ui_toggle[0])) { + && keylen != KEYLEN_PART_MAP) { // No matching mapping found or found a non-matching mapping that // matches at least what the matching mapping matched keylen = 0; diff --git a/src/nvim/globals.h b/src/nvim/globals.h index 3bdbff79b4..82fc7c1218 100644 --- a/src/nvim/globals.h +++ b/src/nvim/globals.h @@ -72,12 +72,6 @@ # define VIMRC_FILE ".nvimrc" #endif -typedef enum { - kNone = -1, - kFalse = 0, - kTrue = 1, -} TriState; - EXTERN struct nvim_stats_s { int64_t fsync; int64_t redraw; diff --git a/src/nvim/keymap.c b/src/nvim/keymap.c index 27052da9d8..eab65f2625 100644 --- a/src/nvim/keymap.c +++ b/src/nvim/keymap.c @@ -309,7 +309,6 @@ static const struct key_name_entry { { K_ZERO, "Nul" }, { K_SNR, "SNR" }, { K_PLUG, "Plug" }, - { K_PASTE, "Paste" }, { K_COMMAND, "Cmd" }, { 0, NULL } // NOTE: When adding a long name update MAX_KEY_NAME_LEN. @@ -941,3 +940,14 @@ char_u *replace_termcodes(const char_u *from, const size_t from_len, return *bufp; } +/// Logs a single key as a human-readable keycode. +void log_key(int log_level, int key) +{ + if (log_level < MIN_LOG_LEVEL) { + return; + } + char *keyname = key == K_EVENT + ? "K_EVENT" + : (char *)get_special_key_name(key, mod_mask); + LOG(log_level, "input: %s", keyname); +} diff --git a/src/nvim/keymap.h b/src/nvim/keymap.h index 7f0483826d..cc02a6fb4f 100644 --- a/src/nvim/keymap.h +++ b/src/nvim/keymap.h @@ -239,14 +239,12 @@ enum key_extra { , KE_DROP = 95 // DnD data is available // , KE_CURSORHOLD = 96 // CursorHold event - , KE_NOP = 97 // doesn't do something + , KE_NOP = 97 // no-op: does nothing , KE_FOCUSGAINED = 98 // focus gained , KE_FOCUSLOST = 99 // focus lost // , KE_MOUSEMOVE = 100 // mouse moved with no button down // , KE_CANCEL = 101 // return from vgetc , KE_EVENT = 102 // event - , KE_PASTE = 103 // special key to toggle the 'paste' option. - // sent only by UIs , KE_COMMAND = 104 // <Cmd> special key }; @@ -443,7 +441,6 @@ enum key_extra { #define K_DROP TERMCAP2KEY(KS_EXTRA, KE_DROP) #define K_EVENT TERMCAP2KEY(KS_EXTRA, KE_EVENT) -#define K_PASTE TERMCAP2KEY(KS_EXTRA, KE_PASTE) #define K_COMMAND TERMCAP2KEY(KS_EXTRA, KE_COMMAND) /* Bits for modifier mask */ diff --git a/src/nvim/lua/vim.lua b/src/nvim/lua/vim.lua index 46c96b455f..fd34b8545d 100644 --- a/src/nvim/lua/vim.lua +++ b/src/nvim/lua/vim.lua @@ -8,8 +8,8 @@ -- -- Guideline: "If in doubt, put it in the runtime". -- --- Most functions should live directly on `vim.`, not sub-modules. The only --- "forbidden" names are those claimed by legacy `if_lua`: +-- Most functions should live directly in `vim.`, not in submodules. +-- The only "forbidden" names are those claimed by legacy `if_lua`: -- $ vim -- :lua for k,v in pairs(vim) do print(k) end -- buffer @@ -161,6 +161,69 @@ local function inspect(object, options) -- luacheck: no unused error(object, options) -- Stub for gen_vimdoc.py end +--- Paste handler, invoked by |nvim_paste()| when a conforming UI +--- (such as the |TUI|) pastes text into the editor. +--- +--@see |paste| +--- +--@param lines |readfile()|-style list of lines to paste. |channel-lines| +--@param phase -1: "non-streaming" paste: the call contains all lines. +--- If paste is "streamed", `phase` indicates the stream state: +--- - 1: starts the paste (exactly once) +--- - 2: continues the paste (zero or more times) +--- - 3: ends the paste (exactly once) +--@returns false if client should cancel the paste. +local function paste(lines, phase) end -- luacheck: no unused +paste = (function() + local tdots, tredraw, tick, got_line1 = 0, 0, 0, false + return function(lines, phase) + local call = vim.api.nvim_call_function + local now = vim.loop.now() + local mode = call('mode', {}):sub(1,1) + if phase < 2 then -- Reset flags. + tdots, tredraw, tick, got_line1 = now, now, 0, false + end + if mode == 'c' and not got_line1 then -- cmdline-mode: paste only 1 line. + got_line1 = (#lines > 1) + vim.api.nvim_set_option('paste', true) -- For nvim_input(). + local line1, _ = string.gsub(lines[1], '[\r\n\012\027]', ' ') + vim.api.nvim_input(line1) -- Scrub "\r". + elseif mode == 'i' or mode == 'R' then + vim.api.nvim_put(lines, 'c', false, true) + else + vim.api.nvim_put(lines, 'c', true, true) + end + if (now - tredraw >= 1000) or phase == -1 or phase > 2 then + tredraw = now + vim.api.nvim_command('redraw') + vim.api.nvim_command('redrawstatus') + end + if phase ~= -1 and (now - tdots >= 100) then + local dots = ('.'):rep(tick % 4) + tdots = now + tick = tick + 1 + -- Use :echo because Lua print('') is a no-op, and we want to clear the + -- message when there are zero dots. + vim.api.nvim_command(('echo "%s"'):format(dots)) + end + if phase == -1 or phase == 3 then + vim.api.nvim_command('echo ""') + vim.api.nvim_set_option('paste', false) + end + return true -- Paste will not continue if not returning `true`. + end +end)() + +--- Defers the wrapped callback until the Nvim API is safe to call. +--- +--@see |vim-loop-callbacks| +local function schedule_wrap(cb) + return (function (...) + local args = {...} + vim.schedule(function() cb(unpack(args)) end) + end) +end + local function __index(t, key) if key == 'inspect' then t.inspect = require('vim.inspect') @@ -172,21 +235,12 @@ local function __index(t, key) end end ---- Defers the wrapped callback until when the nvim API is safe to call. ---- ---- See |vim-loop-callbacks| -local function schedule_wrap(cb) - return (function (...) - local args = {...} - vim.schedule(function() cb(unpack(args)) end) - end) -end - local module = { _update_package_paths = _update_package_paths, _os_proc_children = _os_proc_children, _os_proc_info = _os_proc_info, _system = _system, + paste = paste, schedule_wrap = schedule_wrap, } diff --git a/src/nvim/ops.c b/src/nvim/ops.c index 4f1709bb1f..ebf5c7a7bc 100644 --- a/src/nvim/ops.c +++ b/src/nvim/ops.c @@ -2732,7 +2732,7 @@ void do_put(int regname, yankreg_T *reg, int dir, long count, int flags) * Using inserted text works differently, because the register includes * special characters (newlines, etc.). */ - if (regname == '.') { + if (regname == '.' && !reg) { bool non_linewise_vis = (VIsual_active && VIsual_mode != 'V'); // PUT_LINE has special handling below which means we use 'i' to start. @@ -2815,7 +2815,7 @@ void do_put(int regname, yankreg_T *reg, int dir, long count, int flags) * For special registers '%' (file name), '#' (alternate file name) and * ':' (last command line), etc. we have to create a fake yank register. */ - if (get_spec_reg(regname, &insert_string, &allocated, true)) { + if (!reg && get_spec_reg(regname, &insert_string, &allocated, true)) { if (insert_string == NULL) { return; } @@ -5675,6 +5675,71 @@ end: return target; } +/// @param[out] reg Expected to be empty +bool prepare_yankreg_from_object(yankreg_T *reg, String regtype, size_t lines) +{ + if (regtype.size > 1) { + return false; + } + char type = regtype.data ? regtype.data[0] : NUL; + + switch (type) { + case 0: + reg->y_type = kMTUnknown; + break; + case 'v': case 'c': + reg->y_type = kMTCharWise; + break; + case 'V': case 'l': + reg->y_type = kMTLineWise; + break; + case 'b': case Ctrl_V: + reg->y_type = kMTBlockWise; + break; + default: + return false; + } + + reg->y_array = xcalloc(lines, sizeof(uint8_t *)); + reg->y_size = lines; + reg->additional_data = NULL; + reg->timestamp = 0; + return true; +} + +void finish_yankreg_from_object(yankreg_T *reg, bool clipboard_adjust) +{ + if (reg->y_size > 0 && strlen((char *)reg->y_array[reg->y_size-1]) == 0) { + // a known-to-be charwise yank might have a final linebreak + // but otherwise there is no line after the final newline + if (reg->y_type != kMTCharWise) { + if (reg->y_type == kMTUnknown || clipboard_adjust) { + xfree(reg->y_array[reg->y_size-1]); + reg->y_size--; + } + if (reg->y_type == kMTUnknown) { + reg->y_type = kMTLineWise; + } + } + } else { + if (reg->y_type == kMTUnknown) { + reg->y_type = kMTCharWise; + } + } + + if (reg->y_type == kMTBlockWise) { + size_t maxlen = 0; + for (size_t i = 0; i < reg->y_size; i++) { + size_t rowlen = STRLEN(reg->y_array[i]); + if (rowlen > maxlen) { + maxlen = rowlen; + } + } + assert(maxlen <= INT_MAX); + reg->y_width = (int)maxlen - 1; + } +} + static bool get_clipboard(int name, yankreg_T **target, bool quiet) { // show message on error diff --git a/src/nvim/os/input.c b/src/nvim/os/input.c index 95e9e8e414..83ac3dfa62 100644 --- a/src/nvim/os/input.c +++ b/src/nvim/os/input.c @@ -448,7 +448,7 @@ static void process_interrupts(void) size_t consume_count = 0; RBUFFER_EACH_REVERSE(input_buffer, c, i) { - if ((uint8_t)c == 3) { + if ((uint8_t)c == Ctrl_C) { got_int = true; consume_count = i; break; @@ -456,7 +456,7 @@ static void process_interrupts(void) } if (got_int && consume_count) { - // Remove everything typed before the CTRL-C + // Remove all unprocessed input (typeahead) before the CTRL-C. rbuffer_consumed(input_buffer, consume_count); } } diff --git a/src/nvim/state.c b/src/nvim/state.c index 7c7d035366..dbf04eebec 100644 --- a/src/nvim/state.c +++ b/src/nvim/state.c @@ -65,9 +65,7 @@ getkey: } #if MIN_LOG_LEVEL <= DEBUG_LOG_LEVEL - char *keyname = key == K_EVENT - ? "K_EVENT" : (char *)get_special_key_name(key, mod_mask); - DLOG("input: %s", keyname); + log_key(DEBUG_LOG_LEVEL, key); #endif int execute_result = s->execute(s, key); diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c index 3faf6dd5bb..ffa05e2599 100644 --- a/src/nvim/terminal.c +++ b/src/nvim/terminal.c @@ -475,10 +475,6 @@ static int terminal_execute(VimState *state, int key) TerminalState *s = (TerminalState *)state; switch (key) { - // Temporary fix until paste events gets implemented - case K_PASTE: - break; - case K_LEFTMOUSE: case K_LEFTDRAG: case K_LEFTRELEASE: diff --git a/src/nvim/tui/input.c b/src/nvim/tui/input.c index fe8ffee8e0..c74ef58ba1 100644 --- a/src/nvim/tui/input.c +++ b/src/nvim/tui/input.c @@ -16,7 +16,6 @@ #include "nvim/os/input.h" #include "nvim/event/rstream.h" -#define PASTETOGGLE_KEY "<Paste>" #define KEY_BUFFER_SIZE 0xfff #ifdef INCLUDE_GENERATED_DECLARATIONS @@ -26,7 +25,7 @@ void tinput_init(TermInput *input, Loop *loop) { input->loop = loop; - input->paste_enabled = false; + input->paste = 0; input->in_fd = 0; input->key_buffer = rbuffer_new(KEY_BUFFER_SIZE); uv_mutex_init(&input->key_buffer_mutex); @@ -105,13 +104,28 @@ static void tinput_wait_enqueue(void **argv) { TermInput *input = argv[0]; RBUFFER_UNTIL_EMPTY(input->key_buffer, buf, len) { - size_t consumed = input_enqueue((String){.data = buf, .size = len}); - if (consumed) { - rbuffer_consumed(input->key_buffer, consumed); - } - rbuffer_reset(input->key_buffer); - if (consumed < len) { - break; + const String keys = { .data = buf, .size = len }; + if (input->paste) { + Error err = ERROR_INIT; + // Paste phase: "continue" (unless handler canceled). + input->paste = !nvim_paste(keys, input->paste, &err) + ? 0 : (1 == input->paste ? 2 : input->paste); + rbuffer_consumed(input->key_buffer, len); + rbuffer_reset(input->key_buffer); + if (ERROR_SET(&err)) { + // TODO(justinmk): emsgf() does not display, why? + msg_printf_attr(HL_ATTR(HLF_E)|MSG_HIST, "paste: %s", err.msg); + api_clear_error(&err); + } + } else { + const size_t consumed = input_enqueue(keys); + if (consumed) { + rbuffer_consumed(input->key_buffer, consumed); + } + rbuffer_reset(input->key_buffer); + if (consumed < len) { + break; + } } } uv_mutex_lock(&input->key_buffer_mutex); @@ -292,9 +306,12 @@ static void tk_getkeys(TermInput *input, bool force) } } - if (result != TERMKEY_RES_AGAIN || input->paste_enabled) { + if (result != TERMKEY_RES_AGAIN) { return; } + // else: Partial keypress event was found in the buffer, but it does not + // yet contain all the bytes required. `key` structure indicates what + // termkey_getkey_force() would return. int ms = get_key_code_timeout(); @@ -326,8 +343,8 @@ static bool handle_focus_event(TermInput *input) if (rbuffer_size(input->read_stream.buffer) > 2 && (!rbuffer_cmp(input->read_stream.buffer, "\x1b[I", 3) || !rbuffer_cmp(input->read_stream.buffer, "\x1b[O", 3))) { - // Advance past the sequence bool focus_gained = *rbuffer_get(input->read_stream.buffer, 2) == 'I'; + // Advance past the sequence rbuffer_consumed(input->read_stream.buffer, 3); aucmd_schedule_focusgained(focus_gained); return true; @@ -341,18 +358,33 @@ static bool handle_bracketed_paste(TermInput *input) && (!rbuffer_cmp(input->read_stream.buffer, "\x1b[200~", 6) || !rbuffer_cmp(input->read_stream.buffer, "\x1b[201~", 6))) { bool enable = *rbuffer_get(input->read_stream.buffer, 4) == '0'; + if (input->paste && enable) { + return false; // Pasting "start paste" code literally. + } // Advance past the sequence rbuffer_consumed(input->read_stream.buffer, 6); - if (input->paste_enabled == enable) { - return true; + if (!!input->paste == enable) { + return true; // Spurious "disable paste" code. + } + + if (enable) { + // Flush before starting paste. + tinput_flush(input, true); + // Paste phase: "first-chunk". + input->paste = 1; + } else if (input->paste) { + // Paste phase: "last-chunk". + input->paste = input->paste == 2 ? 3 : -1; + tinput_flush(input, true); + // Paste phase: "disabled". + input->paste = 0; } - tinput_enqueue(input, PASTETOGGLE_KEY, sizeof(PASTETOGGLE_KEY) - 1); - input->paste_enabled = enable; return true; } return false; } +// ESC NUL => <Esc> static bool handle_forced_escape(TermInput *input) { if (rbuffer_size(input->read_stream.buffer) > 1 @@ -477,9 +509,11 @@ static void tinput_read_cb(Stream *stream, RBuffer *buf, size_t count_, continue; } - // Find the next 'esc' and push everything up to it(excluding). This is done - // so the `handle_bracketed_paste`/`handle_forced_escape` calls above work - // as expected. + // + // Find the next ESC and push everything up to it (excluding), so it will + // be the first thing encountered on the next iteration. The `handle_*` + // calls (above) depend on this. + // size_t count = 0; RBUFFER_EACH(input->read_stream.buffer, c, i) { count = i + 1; @@ -488,15 +522,28 @@ static void tinput_read_cb(Stream *stream, RBuffer *buf, size_t count_, break; } } - + // Push bytes directly (paste). + if (input->paste) { + RBUFFER_UNTIL_EMPTY(input->read_stream.buffer, ptr, len) { + size_t consumed = MIN(count, len); + assert(consumed <= input->read_stream.buffer->size); + tinput_enqueue(input, ptr, consumed); + rbuffer_consumed(input->read_stream.buffer, consumed); + if (!(count -= consumed)) { + break; + } + } + continue; + } + // Push through libtermkey (translates to "<keycode>" strings, etc.). RBUFFER_UNTIL_EMPTY(input->read_stream.buffer, ptr, len) { size_t consumed = termkey_push_bytes(input->tk, ptr, MIN(count, len)); // termkey_push_bytes can return (size_t)-1, so it is possible that // `consumed > input->read_stream.buffer->size`, but since tk_getkeys is - // called soon, it shouldn't happen + // called soon, it shouldn't happen. assert(consumed <= input->read_stream.buffer->size); rbuffer_consumed(input->read_stream.buffer, consumed); - // Need to process the keys now since there's no guarantee "count" will + // Process the keys now: there is no guarantee `count` will // fit into libtermkey's input buffer. tk_getkeys(input, false); if (!(count -= consumed)) { @@ -505,7 +552,8 @@ static void tinput_read_cb(Stream *stream, RBuffer *buf, size_t count_, } } while (rbuffer_size(input->read_stream.buffer)); tinput_flush(input, true); - // Make sure the next input escape sequence fits into the ring buffer - // without wrap around, otherwise it could be misinterpreted. + // Make sure the next input escape sequence fits into the ring buffer without + // wraparound, else it could be misinterpreted (because rbuffer_read_ptr() + // exposes the underlying buffer to callers unaware of the wraparound). rbuffer_reset(input->read_stream.buffer); } diff --git a/src/nvim/tui/input.h b/src/nvim/tui/input.h index 7d59cf5c6a..a4071fab40 100644 --- a/src/nvim/tui/input.h +++ b/src/nvim/tui/input.h @@ -9,7 +9,8 @@ typedef struct term_input { int in_fd; - bool paste_enabled; + // Phases: -1=all 0=disabled 1=first-chunk 2=continue 3=last-chunk + int8_t paste; bool waiting; TermKey *tk; #if TERMKEY_VERSION_MAJOR > 0 || TERMKEY_VERSION_MINOR > 18 diff --git a/src/nvim/tui/tui.c b/src/nvim/tui/tui.c index 9fdc6eceba..ea8f9d9f71 100644 --- a/src/nvim/tui/tui.c +++ b/src/nvim/tui/tui.c @@ -430,7 +430,7 @@ static void tui_main(UIBridgeData *bridge, UI *ui) tui_terminal_after_startup(ui); // Tickle `main_loop` with a dummy event, else the initial "focus-gained" // terminal response may not get processed until user hits a key. - loop_schedule_deferred(&main_loop, event_create(tui_dummy_event, 0)); + loop_schedule_deferred(&main_loop, event_create(loop_dummy_event, 0)); } // "Passive" (I/O-driven) loop: TUI thread "main loop". while (!tui_is_stopped(ui)) { @@ -449,10 +449,6 @@ static void tui_main(UIBridgeData *bridge, UI *ui) xfree(data); } -static void tui_dummy_event(void **argv) -{ -} - /// Handoff point between the main (ui_bridge) thread and the TUI thread. static void tui_scheduler(Event event, void *d) { diff --git a/src/nvim/types.h b/src/nvim/types.h index 5bcc0c3e1b..87560a43da 100644 --- a/src/nvim/types.h +++ b/src/nvim/types.h @@ -23,4 +23,10 @@ typedef int LuaRef; typedef struct expand expand_T; +typedef enum { + kNone = -1, + kFalse = 0, + kTrue = 1, +} TriState; + #endif // NVIM_TYPES_H diff --git a/test/functional/api/vim_spec.lua b/test/functional/api/vim_spec.lua index 0cd81619c1..647fab5c43 100644 --- a/test/functional/api/vim_spec.lua +++ b/test/functional/api/vim_spec.lua @@ -5,6 +5,7 @@ local NIL = helpers.NIL local clear, nvim, eq, neq = helpers.clear, helpers.nvim, helpers.eq, helpers.neq local command = helpers.command local eval = helpers.eval +local expect = helpers.expect local funcs = helpers.funcs local iswin = helpers.iswin local meth_pcall = helpers.meth_pcall @@ -365,6 +366,126 @@ describe('API', function() end) end) + describe('nvim_paste', function() + it('validates args', function() + expect_err('Invalid phase: %-2', request, + 'nvim_paste', 'foo', -2) + expect_err('Invalid phase: 4', request, + 'nvim_paste', 'foo', 4) + end) + it('non-streaming', function() + -- With final "\n". + nvim('paste', 'line 1\nline 2\nline 3\n', -1) + expect([[ + line 1 + line 2 + line 3 + ]]) + -- Cursor follows the paste. + eq({0,4,1,0}, funcs.getpos('.')) + eq(false, nvim('get_option', 'paste')) + command('%delete _') + -- Without final "\n". + nvim('paste', 'line 1\nline 2\nline 3', -1) + expect([[ + line 1 + line 2 + line 3]]) + -- Cursor follows the paste. + eq({0,3,6,0}, funcs.getpos('.')) + eq(false, nvim('get_option', 'paste')) + end) + it('vim.paste() failure', function() + nvim('execute_lua', 'vim.paste = (function(lines, phase) error("fake fail") end)', {}) + expect_err([[Error executing lua: %[string "%<nvim>"]:1: fake fail]], + request, 'nvim_paste', 'line 1\nline 2\nline 3', 1) + end) + end) + + describe('nvim_put', function() + it('validates args', function() + expect_err('Invalid lines %(expected array of strings%)', request, + 'nvim_put', {42}, 'l', false, false) + expect_err("Invalid type: 'x'", request, + 'nvim_put', {'foo'}, 'x', false, false) + end) + it("fails if 'nomodifiable'", function() + command('set nomodifiable') + expect_err([[Vim:E21: Cannot make changes, 'modifiable' is off]], request, + 'nvim_put', {'a','b'}, 'l', true, true) + end) + it('inserts text', function() + -- linewise + nvim('put', {'line 1','line 2','line 3'}, 'l', true, true) + expect([[ + + line 1 + line 2 + line 3]]) + eq({0,4,1,0}, funcs.getpos('.')) + command('%delete _') + -- charwise + nvim('put', {'line 1','line 2','line 3'}, 'c', true, false) + expect([[ + line 1 + line 2 + line 3]]) + eq({0,1,1,0}, funcs.getpos('.')) -- follow=false + -- blockwise + nvim('put', {'AA','BB'}, 'b', true, true) + expect([[ + lAAine 1 + lBBine 2 + line 3]]) + eq({0,2,4,0}, funcs.getpos('.')) + command('%delete _') + -- Empty lines list. + nvim('put', {}, 'c', true, true) + eq({0,1,1,0}, funcs.getpos('.')) + expect([[]]) + -- Single empty line. + nvim('put', {''}, 'c', true, true) + eq({0,1,1,0}, funcs.getpos('.')) + expect([[ + ]]) + nvim('put', {'AB'}, 'c', true, true) + -- after=false, follow=true + nvim('put', {'line 1','line 2'}, 'c', false, true) + expect([[ + Aline 1 + line 2B]]) + eq({0,2,7,0}, funcs.getpos('.')) + command('%delete _') + nvim('put', {'AB'}, 'c', true, true) + -- after=false, follow=false + nvim('put', {'line 1','line 2'}, 'c', false, false) + expect([[ + Aline 1 + line 2B]]) + eq({0,1,2,0}, funcs.getpos('.')) + eq('', nvim('eval', 'v:errmsg')) + end) + + it('detects charwise/linewise text (empty {type})', function() + -- linewise (final item is empty string) + nvim('put', {'line 1','line 2','line 3',''}, '', true, true) + expect([[ + + line 1 + line 2 + line 3]]) + eq({0,4,1,0}, funcs.getpos('.')) + command('%delete _') + -- charwise (final item is non-empty) + nvim('put', {'line 1','line 2','line 3'}, '', true, true) + expect([[ + line 1 + line 2 + line 3]]) + eq({0,3,6,0}, funcs.getpos('.')) + end) + end) + describe('nvim_strwidth', function() it('works', function() eq(3, nvim('strwidth', 'abc')) @@ -626,12 +747,12 @@ describe('API', function() -- Make any RPC request (can be non-async: op-pending does not block). nvim('get_current_buf') -- Buffer should not change. - helpers.expect([[ + expect([[ FIRST LINE SECOND LINE]]) -- Now send input to complete the operator. nvim('input', 'j') - helpers.expect([[ + expect([[ first line second line]]) end) @@ -664,7 +785,7 @@ describe('API', function() nvim('get_api_info') -- Send input to complete the mapping. nvim('input', 'd') - helpers.expect([[ + expect([[ FIRST LINE SECOND LINE]]) eq('it worked...', helpers.eval('g:foo')) @@ -680,7 +801,7 @@ describe('API', function() nvim('get_api_info') -- Send input to complete the mapping. nvim('input', 'x') - helpers.expect([[ + expect([[ FIRST LINE SECOND LINfooE]]) end) diff --git a/test/functional/terminal/helpers.lua b/test/functional/terminal/helpers.lua index 18f0b9e4c1..2d99a08614 100644 --- a/test/functional/terminal/helpers.lua +++ b/test/functional/terminal/helpers.lua @@ -1,3 +1,6 @@ +-- To test tui/input.c, this module spawns `nvim` inside :terminal and sends +-- bytes via jobsend(). Note: the functional/helpers.lua test-session methods +-- operate on the _host_ session, _not_ the child session. local helpers = require('test.functional.helpers')(nil) local Screen = require('test.functional.ui.screen') local nvim_dir = helpers.nvim_dir diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua index af55ec1555..5445ff0127 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -1,5 +1,9 @@ -- TUI acceptance tests. -- Uses :terminal as a way to send keys and assert screen state. +-- +-- "bracketed paste" terminal feature: +-- http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Bracketed-Paste-Mode + local helpers = require('test.functional.helpers')(after_each) local uname = helpers.uname local thelpers = require('test.functional.terminal.helpers') @@ -21,11 +25,14 @@ if helpers.pending_win32(pending) then return end describe('TUI', function() local screen + local child_session before_each(function() clear() - screen = thelpers.screen_setup(0, '["'..nvim_prog - ..'", "-u", "NONE", "-i", "NONE", "--cmd", "set noswapfile noshowcmd noruler undodir=. directory=. viewdir=. backupdir=."]') + local child_server = helpers.new_pipename() + screen = thelpers.screen_setup(0, + string.format([=[["%s", "--listen", "%s", "-u", "NONE", "-i", "NONE", "--cmd", "%s laststatus=2 background=dark"]]=], + nvim_prog, child_server, nvim_set)) screen:expect([[ {1: } | {4:~ }| @@ -35,12 +42,31 @@ describe('TUI', function() | {3:-- TERMINAL --} | ]]) + child_session = helpers.connect(child_server) end) after_each(function() screen:detach() end) + -- Wait for mode in the child Nvim (avoid "typeahead race" #10826). + local function wait_for_mode(mode) + retry(nil, nil, function() + local _, m = child_session:request('nvim_get_mode') + eq(mode, m.mode) + end) + end + + -- Assert buffer contents in the child Nvim. + local function expect_child_buf_lines(expected) + assert(type({}) == type(expected)) + retry(nil, nil, function() + local _, buflines = child_session:request( + 'nvim_buf_get_lines', 0, 0, -1, false) + eq(expected, buflines) + end) + end + it('rapid resize #7572 #7628', function() -- Need buffer rows to provoke the behavior. feed_data(":edit test/functional/fixtures/bigfile.txt:") @@ -128,7 +154,7 @@ describe('TUI', function() ]]) end) - it('accepts ascii control sequences', function() + it('accepts ASCII control sequences', function() feed_data('i') feed_data('\022\007') -- ctrl+g feed_data('\022\022') -- ctrl+v @@ -146,49 +172,264 @@ describe('TUI', function() ]], attrs) end) - it('automatically sends <Paste> for bracketed paste sequences', function() - -- Pasting can be really slow in the TUI, specially in ASAN. - -- This will be fixed later but for now we require a high timeout. - screen.timeout = 60000 - feed_data('i\027[200~') + it('paste: Insert mode', function() + -- "bracketed paste" + feed_data('i""\027i\027[200~') screen:expect([[ - {1: } | + "{1:"} | {4:~ }| {4:~ }| {4:~ }| - {5:[No Name] }| - {3:-- INSERT (paste) --} | + {5:[No Name] [+] }| + {3:-- INSERT --} | {3:-- TERMINAL --} | ]]) feed_data('pasted from terminal') + expect_child_buf_lines({'"pasted from terminal"'}) screen:expect([[ - pasted from terminal{1: } | + "pasted from terminal{1:"} | {4:~ }| {4:~ }| {4:~ }| {5:[No Name] [+] }| - {3:-- INSERT (paste) --} | + {3:-- INSERT --} | {3:-- TERMINAL --} | ]]) - feed_data('\027[201~') + feed_data('\027[201~') -- End paste. + feed_data('\027\000') -- ESC: go to Normal mode. + wait_for_mode('n') screen:expect([[ - pasted from terminal{1: } | + "pasted from termina{1:l}" | {4:~ }| {4:~ }| {4:~ }| {5:[No Name] [+] }| - {3:-- INSERT --} | + | + {3:-- TERMINAL --} | + ]]) + -- Dot-repeat/redo. + feed_data('2.') + expect_child_buf_lines( + {'"pasted from terminapasted from terminalpasted from terminall"'}) + screen:expect([[ + "pasted from terminapasted from terminalpasted fro| + m termina{1:l}l" | + {4:~ }| + {4:~ }| + {5:[No Name] [+] }| + | {3:-- TERMINAL --} | ]]) + -- Undo. + feed_data('u') + expect_child_buf_lines({'"pasted from terminal"'}) + feed_data('u') + expect_child_buf_lines({''}) + end) + + it('paste: normal-mode', function() + feed_data(':set ruler') + wait_for_mode('c') + feed_data('\n') + wait_for_mode('n') + local expected = {'line 1', ' line 2', 'ESC:\027 / CR: \013'} + local expected_attr = { + [3] = {bold = true}, + [4] = {foreground = tonumber('0x00000c')}, + [5] = {bold = true, reverse = true}, + [11] = {foreground = tonumber('0x000051')}, + [12] = {reverse = true, foreground = tonumber('0x000051')}, + } + -- "bracketed paste" + feed_data('\027[200~'..table.concat(expected,'\n')..'\027[201~') + screen:expect{ + grid=[[ + line 1 | + line 2 | + ESC:{11:^[} / CR: {12:^}{11:M} | + {4:~ }| + {5:[No Name] [+] 3,13-14 All}| + | + {3:-- TERMINAL --} | + ]], + attr_ids=expected_attr} + -- Dot-repeat/redo. + feed_data('.') + screen:expect{ + grid=[[ + line 2 | + ESC:{11:^[} / CR: {11:^M}line 1 | + line 2 | + ESC:{11:^[} / CR: {12:^}{11:M} | + {5:[No Name] [+] 5,13-14 Bot}| + | + {3:-- TERMINAL --} | + ]], + attr_ids=expected_attr} + -- Undo. + feed_data('u') + expect_child_buf_lines(expected) + feed_data('u') + expect_child_buf_lines({''}) + end) + + it('paste: cmdline-mode inserts 1 line', function() + feed_data('ifoo\n') -- Insert some text (for dot-repeat later). + feed_data('\027:""') -- Enter Cmdline-mode. + feed_data('\027[D') -- <Left> to place cursor between quotes. + wait_for_mode('c') + -- "bracketed paste" + feed_data('\027[200~line 1\nline 2\n\027[201~') + screen:expect{grid=[[ + foo | + | + {4:~ }| + {4:~ }| + {5:[No Name] [+] }| + :"line 1{1:"} | + {3:-- TERMINAL --} | + ]]} + -- Dot-repeat/redo. + feed_data('\027\000') + wait_for_mode('n') + feed_data('.') + screen:expect{grid=[[ + foo | + foo | + {1: } | + {4:~ }| + {5:[No Name] [+] }| + | + {3:-- TERMINAL --} | + ]]} + end) + + it('paste: cmdline-mode collects chunks of unfinished line', function() + local function expect_cmdline(expected) + retry(nil, nil, function() + local _, cmdline = child_session:request( + 'nvim_call_function', 'getcmdline', {}) + eq(expected, cmdline) + end) + end + feed_data('\027:""') -- Enter Cmdline-mode. + feed_data('\027[D') -- <Left> to place cursor between quotes. + wait_for_mode('c') + feed_data('\027[200~stuff 1 ') + expect_cmdline('"stuff 1 "') + -- Discards everything after the first line. + feed_data('more\nstuff 2\nstuff 3\n') + expect_cmdline('"stuff 1 more"') + feed_data('stuff 3') + expect_cmdline('"stuff 1 more"') + -- End the paste sequence. + feed_data('\027[201~') + feed_data(' typed') + expect_cmdline('"stuff 1 more typed"') + end) + + it('paste: recovers from vim.paste() failure', function() + child_session:request('nvim_execute_lua', [[ + _G.save_paste_fn = vim.paste + vim.paste = function(lines, phase) error("fake fail") end + ]], {}) + -- Prepare something for dot-repeat/redo. + feed_data('ifoo\n\027\000') + wait_for_mode('n') + screen:expect{grid=[[ + foo | + {1: } | + {4:~ }| + {4:~ }| + {5:[No Name] [+] }| + | + {3:-- TERMINAL --} | + ]]} + -- Start pasting... + feed_data('\027[200~line 1\nline 2\n') + wait_for_mode('n') + screen:expect{any='paste: Error executing lua'} + -- Remaining chunks are discarded after vim.paste() failure. + feed_data('line 3\nline 4\n') + feed_data('line 5\nline 6\n') + feed_data('line 7\nline 8\n') + -- Stop paste. + feed_data('\027[201~') + feed_data('\n') -- <Enter> + --Dot-repeat/redo is not modified by failed paste. + feed_data('.') + screen:expect{grid=[[ + foo | + foo | + {1: } | + {4:~ }| + {5:[No Name] [+] }| + | + {3:-- TERMINAL --} | + ]]} + -- Editor should still work after failed/drained paste. + feed_data('ityped input...\027\000') + screen:expect{grid=[[ + foo | + foo | + typed input..{1:.} | + {4:~ }| + {5:[No Name] [+] }| + | + {3:-- TERMINAL --} | + ]]} + -- Paste works if vim.paste() succeeds. + child_session:request('nvim_execute_lua', [[ + vim.paste = _G.save_paste_fn + ]], {}) + feed_data('\027[200~line A\nline B\n\027[201~') + feed_data('\n') -- <Enter> + screen:expect{grid=[[ + foo | + typed input...line A | + line B | + {1: } | + {5:[No Name] [+] }| + | + {3:-- TERMINAL --} | + ]]} end) - it('handles pasting a specific amount of text', function() - -- Need extra time for this test, specially in ASAN. - screen.timeout = 60000 - feed_data('i\027[200~'..string.rep('z', 64)..'\027[201~') + it("paste: 'nomodifiable' buffer", function() + child_session:request('nvim_command', 'set nomodifiable') + feed_data('\027[200~fail 1\nfail 2\n\027[201~') + screen:expect{any='Vim:E21'} + feed_data('\n') -- <Enter> + child_session:request('nvim_command', 'set modifiable') + feed_data('\027[200~success 1\nsuccess 2\n\027[201~') + screen:expect{grid=[[ + success 1 | + success 2 | + {1: } | + {4:~ }| + {5:[No Name] [+] }| + | + {3:-- TERMINAL --} | + ]]} + end) + + -- TODO + it('paste: other modes', function() + -- Other modes act like CTRL-C + paste. + end) + + it('paste: exactly 64 bytes #10311', function() + local expected = string.rep('z', 64) + feed_data('i') + wait_for_mode('i') + -- "bracketed paste" + feed_data('\027[200~'..expected..'\027[201~') + feed_data(' end') + expected = expected..' end' + expect_child_buf_lines({expected}) screen:expect([[ zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz| - zzzzzzzzzzzzzz{1: } | + zzzzzzzzzzzzzz end{1: } | {4:~ }| {4:~ }| {5:[No Name] [+] }| @@ -197,26 +438,95 @@ describe('TUI', function() ]]) end) - it('can handle arbitrarily long bursts of input', function() - -- Need extra time for this test, specially in ASAN. - screen.timeout = 60000 - feed_command('set ruler') + it('paste: big burst of input', function() + feed_data(':set ruler\n') local t = {} for i = 1, 3000 do t[i] = 'item ' .. tostring(i) end - feed_data('i\027[200~'..table.concat(t, '\n')..'\027[201~') + feed_data('i') + wait_for_mode('i') + -- "bracketed paste" + feed_data('\027[200~'..table.concat(t, '\n')..'\027[201~') + expect_child_buf_lines(t) + feed_data(' end') + screen:expect([[ + item 2997 | + item 2998 | + item 2999 | + item 3000 end{1: } | + {5:[No Name] [+] 3000,14 Bot}| + {3:-- INSERT --} | + {3:-- TERMINAL --} | + ]]) + feed_data('\027\000') -- ESC: go to Normal mode. + wait_for_mode('n') + -- Dot-repeat/redo. + feed_data('.') screen:expect([[ item 2997 | item 2998 | item 2999 | - item 3000{1: } | - {5:[No Name] [+] 3000,10 Bot}| + item 3000 en{1:d}d | + {5:[No Name] [+] 5999,13 Bot}| + | + {3:-- TERMINAL --} | + ]]) + end) + + it('paste: forwards spurious "start paste" code', function() + -- If multiple "start paste" sequences are sent without a corresponding + -- "stop paste" sequence, only the first occurrence should be consumed. + + -- Send the "start paste" sequence. + feed_data('i\027[200~') + feed_data('\npasted from terminal (1)\n') + -- Send spurious "start paste" sequence. + feed_data('\027[200~') + feed_data('\n') + -- Send the "stop paste" sequence. + feed_data('\027[201~') + + screen:expect{grid=[[ + | + pasted from terminal (1) | + {6:^[}[200~ | + {1: } | + {5:[No Name] [+] }| + {3:-- INSERT --} | + {3:-- TERMINAL --} | + ]], + attr_ids={ + [1] = {reverse = true}, + [2] = {background = tonumber('0x00000b')}, + [3] = {bold = true}, + [4] = {foreground = tonumber('0x00000c')}, + [5] = {bold = true, reverse = true}, + [6] = {foreground = tonumber('0x000051')}, + }} + end) + + it('paste: ignores spurious "stop paste" code', function() + -- If "stop paste" sequence is received without a preceding "start paste" + -- sequence, it should be ignored. + feed_data('i') + -- Send "stop paste" sequence. + feed_data('\027[201~') + screen:expect([[ + {1: } | + {4:~ }| + {4:~ }| + {4:~ }| + {5:[No Name] }| {3:-- INSERT --} | {3:-- TERMINAL --} | ]]) end) + -- TODO + it('paste: handles missing "stop paste" code', function() + end) + it('allows termguicolors to be set at runtime', function() screen:set_option('rgb', true) screen:set_default_attr_ids({ diff --git a/test/functional/ui/input_spec.lua b/test/functional/ui/input_spec.lua index 0009f2c31b..12d0e4f40b 100644 --- a/test/functional/ui/input_spec.lua +++ b/test/functional/ui/input_spec.lua @@ -110,77 +110,6 @@ describe('mappings', function() end) end) -describe('feeding large chunks of input with <Paste>', function() - local screen - before_each(function() - clear() - screen = Screen.new() - screen:attach() - feed_command('set ruler') - end) - - it('ok', function() - if helpers.skip_fragile(pending) then - return - end - local t = {} - for i = 1, 20000 do - t[i] = 'item ' .. tostring(i) - end - feed('i<Paste>') - screen:expect([[ - ^ | - ~ | - ~ | - ~ | - ~ | - ~ | - ~ | - ~ | - ~ | - ~ | - ~ | - ~ | - ~ | - -- INSERT (paste) -- | - ]]) - feed(table.concat(t, '<Enter>')) - screen:expect([[ - item 19988 | - item 19989 | - item 19990 | - item 19991 | - item 19992 | - item 19993 | - item 19994 | - item 19995 | - item 19996 | - item 19997 | - item 19998 | - item 19999 | - item 20000^ | - -- INSERT (paste) -- | - ]]) - feed('<Paste>') - screen:expect([[ - item 19988 | - item 19989 | - item 19990 | - item 19991 | - item 19992 | - item 19993 | - item 19994 | - item 19995 | - item 19996 | - item 19997 | - item 19998 | - item 19999 | - item 20000^ | - -- INSERT -- 20000,11 Bot | - ]]) - end) -end) - describe('input utf sequences that contain CSI/K_SPECIAL', function() before_each(clear) it('ok', function() |