diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 745e84d02e..4df7d6442a 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -183,7 +183,13 @@ CHANGED FEATURES *news-changed* These existing features changed their behavior. -• N/A +• 'scrollbind' now works properly with buffers that contain virutal lines. + + Scrollbind works by aligning to a target top line of each window in a tab + page. Previously this was done by calculating the difference between the old + top line and the target top line, and scrolling by that amount. Now the + top lines are calculated using screen line numbers which take virtual lines + into account. ============================================================================== REMOVED FEATURES *news-removed* diff --git a/src/nvim/decoration.c b/src/nvim/decoration.c index fb6a92025f..70696f1f03 100644 --- a/src/nvim/decoration.c +++ b/src/nvim/decoration.c @@ -887,7 +887,8 @@ bool decor_redraw_eol(win_T *wp, DecorState *state, int *eol_attr, int eol_col) static const uint32_t lines_filter[4] = {[kMTMetaLines] = kMTFilterSelect }; -int decor_virt_lines(win_T *wp, linenr_T lnum, VirtLines *lines) +/// @param apply_folds Only count virtual lines that are not in folds. +int decor_virt_lines(win_T *wp, int start_row, int end_row, VirtLines *lines, bool apply_folds) { buf_T *buf = wp->w_buffer; if (!buf_meta_total(buf, kMTMetaLines)) { @@ -896,15 +897,14 @@ int decor_virt_lines(win_T *wp, linenr_T lnum, VirtLines *lines) return 0; } - assert(lnum > 0); - int row = lnum - 1; - MarkTreeIter itr[1] = { 0 }; - if (!marktree_itr_get_filter(buf->b_marktree, MAX(row - 1, 0), 0, row + 1, 0, + if (!marktree_itr_get_filter(buf->b_marktree, MAX(start_row - 1, 0), 0, end_row, 0, lines_filter, itr)) { return 0; } + assert(start_row >= 0); + int virt_lines = 0; while (true) { MTKey mark = marktree_itr_current(itr); @@ -915,7 +915,8 @@ int decor_virt_lines(win_T *wp, linenr_T lnum, VirtLines *lines) bool above = vt->flags & kVTLinesAbove; int mrow = mark.pos.row; int draw_row = mrow + (above ? 0 : 1); - if (draw_row == row && !hasFolding(wp, mrow + 1, NULL, NULL)) { + if (draw_row >= start_row && draw_row < end_row + && (!apply_folds || !hasFolding(wp, mrow + 1, NULL, NULL))) { virt_lines += (int)kv_size(vt->data.virt_lines); if (lines) { kv_splice(*lines, vt->data.virt_lines); @@ -926,7 +927,7 @@ int decor_virt_lines(win_T *wp, linenr_T lnum, VirtLines *lines) } } - if (!marktree_itr_next_filter(buf->b_marktree, itr, row + 1, 0, lines_filter)) { + if (!marktree_itr_next_filter(buf->b_marktree, itr, end_row, 0, lines_filter)) { break; } } diff --git a/src/nvim/diff.c b/src/nvim/diff.c index 0b9bdb6181..6d5c301e81 100644 --- a/src/nvim/diff.c +++ b/src/nvim/diff.c @@ -2143,7 +2143,11 @@ int diff_check_with_linestatus(win_T *wp, linenr_T lnum, int *linestatus) return 0; } - if (!dp->is_linematched && diff_linematch(dp)) { + // Don't run linematch when lnum is offscreen. + // Useful for scrollbind calculations which need to count all the filler lines + // above the screen. + if (lnum >= wp->w_topline && lnum < wp->w_botline + && !dp->is_linematched && diff_linematch(dp)) { run_linematch_algorithm(dp); } diff --git a/src/nvim/drawline.c b/src/nvim/drawline.c index 47d84e6539..8a948716e5 100644 --- a/src/nvim/drawline.c +++ b/src/nvim/drawline.c @@ -1156,7 +1156,7 @@ int win_line(win_T *wp, linenr_T lnum, int startrow, int endrow, int col_rows, s area_highlighting = true; } VirtLines virt_lines = KV_INITIAL_VALUE; - wlv.n_virt_lines = decor_virt_lines(wp, lnum, &virt_lines); + wlv.n_virt_lines = decor_virt_lines(wp, lnum - 1, lnum, &virt_lines, true); wlv.filler_lines += wlv.n_virt_lines; if (lnum == wp->w_topline) { wlv.filler_lines = wp->w_topfill; diff --git a/src/nvim/ex_cmds.c b/src/nvim/ex_cmds.c index 5cc9a8c106..f170fd0762 100644 --- a/src/nvim/ex_cmds.c +++ b/src/nvim/ex_cmds.c @@ -2695,7 +2695,7 @@ int do_ecmd(int fnum, char *ffname, char *sfname, exarg_T *eap, linenr_T newlnum *so_ptr = 999; // force cursor to be vertically centered in the window } update_topline(curwin); - curwin->w_scbind_pos = curwin->w_topline; + curwin->w_scbind_pos = plines_m_win_fill(curwin, 1, curwin->w_topline); *so_ptr = n; redraw_curbuf_later(UPD_NOT_VALID); // redraw this buffer later } diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index 2defd580fc..e384627fec 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -81,6 +81,7 @@ #include "nvim/os/os_defs.h" #include "nvim/os/shell.h" #include "nvim/path.h" +#include "nvim/plines.h" #include "nvim/popupmenu.h" #include "nvim/pos_defs.h" #include "nvim/profile.h" @@ -5580,39 +5581,43 @@ static void ex_swapname(exarg_T *eap) /// (1998-11-02 16:21:01 R. Edward Ralston ) static void ex_syncbind(exarg_T *eap) { - linenr_T topline; + linenr_T vtopline; // Target topline (including fill) + linenr_T old_linenr = curwin->w_cursor.lnum; setpcmark(); - // determine max topline + // determine max (virtual) topline if (curwin->w_p_scb) { - topline = curwin->w_topline; + vtopline = get_vtopline(curwin); FOR_ALL_WINDOWS_IN_TAB(wp, curtab) { if (wp->w_p_scb && wp->w_buffer) { - topline = MIN(topline, wp->w_buffer->b_ml.ml_line_count - get_scrolloff_value(curwin)); + linenr_T y = plines_m_win_fill(wp, 1, wp->w_buffer->b_ml.ml_line_count) + - get_scrolloff_value(curwin); + vtopline = MIN(vtopline, y); } } - topline = MAX(topline, 1); + vtopline = MAX(vtopline, 1); } else { - topline = 1; + vtopline = 1; } // Set all scrollbind windows to the same topline. FOR_ALL_WINDOWS_IN_TAB(wp, curtab) { if (wp->w_p_scb) { - int y = topline - wp->w_topline; + int y = vtopline - get_vtopline(wp); if (y > 0) { scrollup(wp, y, true); } else { scrolldown(wp, -y, true); } - wp->w_scbind_pos = topline; + wp->w_scbind_pos = vtopline; redraw_later(wp, UPD_VALID); cursor_correct(wp); wp->w_redr_status = true; } } + if (curwin->w_p_scb) { did_syncbind = true; checkpcmark(); diff --git a/src/nvim/normal.c b/src/nvim/normal.c index 88b8ccbb85..3931ae3ee9 100644 --- a/src/nvim/normal.c +++ b/src/nvim/normal.c @@ -2092,17 +2092,23 @@ static void display_showcmd(void) grid_line_flush(); } +int get_vtopline(win_T *wp) +{ + return plines_m_win_fill(wp, 1, wp->w_topline) - wp->w_topfill; +} + /// When "check" is false, prepare for commands that scroll the window. /// When "check" is true, take care of scroll-binding after the window has /// scrolled. Called from normal_cmd() and edit(). void do_check_scrollbind(bool check) { static win_T *old_curwin = NULL; - static linenr_T old_topline = 0; - static int old_topfill = 0; + static linenr_T old_vtopline = 0; static buf_T *old_buf = NULL; static colnr_T old_leftcol = 0; + int vtopline = get_vtopline(curwin); + if (check && curwin->w_p_scb) { // If a ":syncbind" command was just used, don't scroll, only reset // the values. @@ -2115,10 +2121,9 @@ void do_check_scrollbind(bool check) if ((curwin->w_buffer == old_buf || curwin->w_p_diff ) - && (curwin->w_topline != old_topline - || curwin->w_topfill != old_topfill + && (vtopline != old_vtopline || curwin->w_leftcol != old_leftcol)) { - check_scrollbind(curwin->w_topline - old_topline, curwin->w_leftcol - old_leftcol); + check_scrollbind(vtopline - old_vtopline, curwin->w_leftcol - old_leftcol); } } else if (vim_strchr(p_sbo, 'j')) { // jump flag set in 'scrollopt' // When switching between windows, make sure that the relative @@ -2129,14 +2134,13 @@ void do_check_scrollbind(bool check) // resync is performed, some of the other 'scrollbind' windows may // need to jump so that the current window's relative position is // visible on-screen. - check_scrollbind(curwin->w_topline - (linenr_T)curwin->w_scbind_pos, 0); + check_scrollbind(vtopline - curwin->w_scbind_pos, 0); } - curwin->w_scbind_pos = curwin->w_topline; + curwin->w_scbind_pos = vtopline; } old_curwin = curwin; - old_topline = curwin->w_topline; - old_topfill = curwin->w_topfill; + old_vtopline = vtopline; old_buf = curwin->w_buffer; old_leftcol = curwin->w_leftcol; } @@ -2144,20 +2148,18 @@ void do_check_scrollbind(bool check) /// Synchronize any windows that have "scrollbind" set, based on the /// number of rows by which the current window has changed /// (1998-11-02 16:21:01 R. Edward Ralston ) -void check_scrollbind(linenr_T topline_diff, int leftcol_diff) +void check_scrollbind(linenr_T vtopline_diff, int leftcol_diff) { win_T *old_curwin = curwin; buf_T *old_curbuf = curbuf; int old_VIsual_select = VIsual_select; int old_VIsual_active = VIsual_active; colnr_T tgt_leftcol = curwin->w_leftcol; - linenr_T topline; - linenr_T y; // check 'scrollopt' string for vertical and horizontal scroll options - bool want_ver = (vim_strchr(p_sbo, 'v') && topline_diff != 0); - want_ver |= old_curwin->w_p_diff; - bool want_hor = (vim_strchr(p_sbo, 'h') && (leftcol_diff || topline_diff != 0)); + bool want_ver = old_curwin->w_p_diff + || (vim_strchr(p_sbo, 'v') && vtopline_diff != 0); + bool want_hor = (vim_strchr(p_sbo, 'h') && (leftcol_diff || vtopline_diff != 0)); // loop through the scrollbound windows and scroll accordingly VIsual_select = VIsual_active = 0; @@ -2174,16 +2176,19 @@ void check_scrollbind(linenr_T topline_diff, int leftcol_diff) if (old_curwin->w_p_diff && curwin->w_p_diff) { diff_set_topline(old_curwin, curwin); } else { - curwin->w_scbind_pos += topline_diff; - topline = (linenr_T)curwin->w_scbind_pos; - if (topline > curbuf->b_ml.ml_line_count) { - topline = curbuf->b_ml.ml_line_count; - } - if (topline < 1) { - topline = 1; - } + curwin->w_scbind_pos += vtopline_diff; + int curr_vtopline = get_vtopline(curwin); - y = topline - curwin->w_topline; + // Perf: reuse curr_vtopline to reduce the time in plines_m_win_fill(). + // Equivalent to: + // int max_vtopline = plines_m_win_fill(curwin, 1, curbuf->b_ml.ml_line_count); + int max_vtopline = curr_vtopline + curwin->w_topfill + + plines_m_win_fill(curwin, curwin->w_topline + 1, + curbuf->b_ml.ml_line_count); + + int new_vtopline = MAX(MIN((linenr_T)curwin->w_scbind_pos, max_vtopline), 1); + + int y = new_vtopline - curr_vtopline; if (y > 0) { scrollup(curwin, y, false); } else { diff --git a/src/nvim/option.c b/src/nvim/option.c index 8fb97ed979..e7d8bb91ac 100644 --- a/src/nvim/option.c +++ b/src/nvim/option.c @@ -88,6 +88,7 @@ #include "nvim/os/os.h" #include "nvim/os/os_defs.h" #include "nvim/path.h" +#include "nvim/plines.h" #include "nvim/popupmenu.h" #include "nvim/pos_defs.h" #include "nvim/regexp.h" @@ -2474,7 +2475,7 @@ static const char *did_set_scrollbind(optset_T *args) return NULL; } do_check_scrollbind(false); - win->w_scbind_pos = win->w_topline; + win->w_scbind_pos = get_vtopline(win); return NULL; } diff --git a/src/nvim/plines.c b/src/nvim/plines.c index 4409b14ae1..e51e9bf8c3 100644 --- a/src/nvim/plines.c +++ b/src/nvim/plines.c @@ -712,7 +712,7 @@ bool win_may_fill(win_T *wp) /// @return Number of filler lines above lnum int win_get_fill(win_T *wp, linenr_T lnum) { - int virt_lines = decor_virt_lines(wp, lnum, NULL); + int virt_lines = decor_virt_lines(wp, lnum - 1, lnum, NULL, true); // be quick when there are no filler lines if (diffopt_filler()) { @@ -906,6 +906,25 @@ int plines_m_win(win_T *wp, linenr_T first, linenr_T last, int max) return MIN(max, count); } +/// Return number of window lines a physical line range will occupy. +/// Only considers real and filler lines. +/// +/// Mainly used for calculating scrolling offsets. +int plines_m_win_fill(win_T *wp, linenr_T first, linenr_T last) +{ + int count = last - first + 1 + decor_virt_lines(wp, first - 1, last, NULL, false); + + if (diffopt_filler()) { + for (int lnum = first; lnum <= last; lnum++) { + // Note: this also considers folds. + int n = diff_check(wp, lnum); + count += MAX(n, 0); + } + } + + return MAX(count, 0); +} + /// Get the number of screen lines a range of text will take in window "wp". /// /// @param[in] start_lnum Starting line number, 1-based inclusive. diff --git a/test/functional/testnvim.lua b/test/functional/testnvim.lua index 5a9e7f8c29..66ce6daacb 100644 --- a/test/functional/testnvim.lua +++ b/test/functional/testnvim.lua @@ -835,9 +835,18 @@ function M.exec_capture(code) return M.api.nvim_exec2(code, { output = true }).output end ---- @param code string +--- @param code string|function --- @return any function M.exec_lua(code, ...) + if type(code) == 'function' then + return M.api.nvim_exec_lua( + [[ + local code = ... + return loadstring(code)(select(2, ...)) + ]], + { string.dump(code), ... } + ) + end return M.api.nvim_exec_lua(code, { ... }) end diff --git a/test/functional/ui/scrollbind_spec.lua b/test/functional/ui/scrollbind_spec.lua new file mode 100644 index 0000000000..9e70b25efa --- /dev/null +++ b/test/functional/ui/scrollbind_spec.lua @@ -0,0 +1,442 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() +local clear = n.clear +local Screen = require('test.functional.ui.screen') + +before_each(clear) + +describe('Scrollbind', function() + local screen --- @type test.functional.ui.screen + + before_each(function() + screen = Screen.new(40, 12) + screen:attach() + end) + + it('works with one buffer with virtual lines', function() + n.exec_lua(function() + local lines = {} --- @type string[] + + for i = 1, 20 do + lines[i] = tostring(i * 2 - 1) + end + + local ns = vim.api.nvim_create_namespace('test') + + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + vim.bo.buftype = 'nofile' + + for i in ipairs(lines) do + vim.api.nvim_buf_set_extmark(0, ns, i - 1, 0, { + virt_lines = { { { tostring(2 * i) .. ' v' } } }, + }) + end + + vim.wo.scrollbind = true + vim.cmd.vsplit() + vim.wo.scrollbind = true + end) + + n.feed('') + + t.eq(5, n.api.nvim_get_option_value('scroll', {})) + + screen:expect({ + grid = [[ + 6 v │6 v | + 7 │7 | + 8 v │8 v | + 9 │9 | + 10 v │10 v | + ^11 │11 | + 12 v │12 v | + 13 │13 | + 14 v │14 v | + 15 │15 | + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + + n.feed('') + + local line1_grid = [[ + ^1 │1 | + 2 v │2 v | + 3 │3 | + 4 v │4 v | + 5 │5 | + 6 v │6 v | + 7 │7 | + 8 v │8 v | + 9 │9 | + 10 v │10 v | + {3:[Scratch] }{2:[Scratch] }| + | + ]] + + screen:expect({ grid = line1_grid }) + + n.api.nvim_set_option_value('scroll', 6, {}) + + n.feed('') + + screen:expect({ + grid = [[ + 7 │7 | + 8 v │8 v | + 9 │9 | + 10 v │10 v | + 11 │11 | + 12 v │12 v | + ^13 │13 | + 14 v │14 v | + 15 │15 | + 16 v │16 v | + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + + n.feed('') + + screen:expect({ grid = line1_grid }) + end) + + it('works with two buffers with virtual lines on one side', function() + n.exec_lua(function() + local lines = {} --- @type string[] + + for i = 1, 20 do + lines[i] = tostring(i) + end + + local ns = vim.api.nvim_create_namespace('test') + + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + vim.bo.buftype = 'nofile' + + vim.wo.scrollbind = true + vim.cmd.vnew() + + lines = {} --- @type string[] + + for i = 1, 20 do + lines[i] = tostring(i + (i > 3 and 4 or 0)) + end + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + vim.bo.buftype = 'nofile' + + vim.api.nvim_buf_set_extmark(0, ns, 2, 0, { + virt_lines = { + { { '4 v' } }, + { { '5 v' } }, + { { '6 v' } }, + { { '7 v' } }, + }, + }) + + vim.wo.scrollbind = true + end) + + n.feed('') + + t.eq(5, n.api.nvim_get_option_value('scroll', {})) + + screen:expect({ + grid = [[ + 6 v │6 | + 7 v │7 | + 8 │8 | + 9 │9 | + ^10 │10 | + 11 │11 | + 12 │12 | + 13 │13 | + 14 │14 | + 15 │15 | + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + + n.feed('') + + local line1_grid = [[ + ^1 │1 | + 2 │2 | + 3 │3 | + 4 v │4 | + 5 v │5 | + 6 v │6 | + 7 v │7 | + 8 │8 | + 9 │9 | + 10 │10 | + {3:[Scratch] }{2:[Scratch] }| + | + ]] + + screen:expect({ grid = line1_grid }) + + n.api.nvim_set_option_value('scroll', 6, {}) + + n.feed('') + + screen:expect({ + grid = [[ + 7 v │7 | + 8 │8 | + 9 │9 | + 10 │10 | + ^11 │11 | + 12 │12 | + 13 │13 | + 14 │14 | + 15 │15 | + 16 │16 | + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + + n.feed('') + + screen:expect({ grid = line1_grid }) + + -- Note: not the same as n.feed('4') + n.feed('') + n.feed('') + n.feed('') + n.feed('') + + screen:expect({ + grid = [[ + 5 v │5 | + 6 v │6 | + 7 v │7 | + ^8 │8 | + 9 │9 | + 10 │10 | + 11 │11 | + 12 │12 | + 13 │13 | + 14 │14 | + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + + n.feed('') + + screen:expect({ + grid = [[ + 6 v │6 | + 7 v │7 | + ^8 │8 | + 9 │9 | + 10 │10 | + 11 │11 | + 12 │12 | + 13 │13 | + 14 │14 | + 15 │15 | + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + + n.feed('') + n.feed('') + + screen:expect({ + grid = [[ + 4 v │4 | + 5 v │5 | + 6 v │6 | + 7 v │7 | + ^8 │8 | + 9 │9 | + 10 │10 | + 11 │11 | + 12 │12 | + 13 │13 | + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + end) + + it('works with buffers of different lengths', function() + n.exec_lua(function() + vim.api.nvim_buf_set_lines(0, 0, -1, false, { '1', '2', '3' }) + vim.bo.buftype = 'nofile' + + vim.wo.scrollbind = true + vim.cmd.vnew() + + local lines = {} --- @type string[] + + for i = 1, 50 do + lines[i] = tostring(i) + end + + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + vim.bo.buftype = 'nofile' + vim.wo.scrollbind = true + end) + + n.feed('10') + + screen:expect({ + grid = [[ + ^11 │3 | + 12 │{1:~ }| + 13 │{1:~ }| + 14 │{1:~ }| + 15 │{1:~ }| + 16 │{1:~ }| + 17 │{1:~ }| + 18 │{1:~ }| + 19 │{1:~ }| + 20 │{1:~ }| + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + + n.feed('') + + screen:expect({ + grid = [[ + 10 │3 | + ^11 │{1:~ }| + 12 │{1:~ }| + 13 │{1:~ }| + 14 │{1:~ }| + 15 │{1:~ }| + 16 │{1:~ }| + 17 │{1:~ }| + 18 │{1:~ }| + 19 │{1:~ }| + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + end) + + it('works with buffers of different lengths and virtual lines', function() + n.exec_lua(function() + vim.api.nvim_buf_set_lines(0, 0, -1, false, { '1', '5', '6' }) + + local ns = vim.api.nvim_create_namespace('test') + vim.api.nvim_buf_set_extmark(0, ns, 0, 0, { + virt_lines = { + { { '2 v' } }, + { { '3 v' } }, + { { '4 v' } }, + }, + }) + + vim.bo.buftype = 'nofile' + + vim.wo.scrollbind = true + vim.cmd.vnew() + + local lines = {} --- @type string[] + + for i = 1, 50 do + lines[i] = tostring(i) + end + + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + vim.bo.buftype = 'nofile' + vim.wo.scrollbind = true + end) + + n.feed('') + n.feed('') + screen:expect({ + grid = [[ + ^3 │3 v | + 4 │4 v | + 5 │5 | + 6 │6 | + 7 │{1:~ }| + 8 │{1:~ }| + 9 │{1:~ }| + 10 │{1:~ }| + 11 │{1:~ }| + 12 │{1:~ }| + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + + n.feed('8') + + screen:expect({ + grid = [[ + ^11 │6 | + 12 │{1:~ }| + 13 │{1:~ }| + 14 │{1:~ }| + 15 │{1:~ }| + 16 │{1:~ }| + 17 │{1:~ }| + 18 │{1:~ }| + 19 │{1:~ }| + 20 │{1:~ }| + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + + n.feed('') + n.feed('') + n.feed('') + n.feed('') + n.feed('') + + t.eq(n.exec_lua [[return vim.fn.line('w0', 1001)]], 6) + t.eq(n.exec_lua [[return vim.fn.line('w0', 1000)]], 3) + + screen:expect({ + grid = [[ + 6 │6 | + 7 │{1:~ }| + 8 │{1:~ }| + 9 │{1:~ }| + 10 │{1:~ }| + ^11 │{1:~ }| + 12 │{1:~ }| + 13 │{1:~ }| + 14 │{1:~ }| + 15 │{1:~ }| + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + + n.feed('') + n.feed('') + n.feed('') + + screen:expect({ + grid = [[ + 3 │3 v | + 4 │4 v | + 5 │5 | + 6 │6 | + 7 │{1:~ }| + 8 │{1:~ }| + 9 │{1:~ }| + 10 │{1:~ }| + ^11 │{1:~ }| + 12 │{1:~ }| + {3:[Scratch] }{2:[Scratch] }| + | + ]], + }) + end) +end)