commit 1255a8d88d508e6c11d0addcd67d3959a2fb76cf
parent 444a8b3ec6375b03f1483a97095a00b067a499ec
Author: brianhuster <phambinhanctb2004@gmail.com>
Date: Sun, 13 Jul 2025 23:44:39 +0700
refactor(tutor): reimplement interactive marks as extmark in Lua
Problem:
From https://matrix.to/#/!cylwlNXSwagQmZSkzs:matrix.org/$Ofj-TFIsEMbp0O9OhE8xuZSNi-nhRLtZTOgs6JRLNrs?via=matrix.org&via=gitter.im&via=mozilla.org
In lesson 2.6, users are asked to remove the second, forth and fifth
lines with `dd` command, then they are asked to undo twice to make the
text go back to original state. But after that, the mark ✗ appears
again, which confuses the user because they think they do something
wrong. This is a limitation with the current implementation, which is
based on line number only.
Solution:
Reimplement interactive marks as extmarks in Lua. This also make the
feature less fragile, as users can remove, add some arbitrary lines
without breaking the interactive marks.
Co-authored-by: Justin M. Keyes <justinkz@gmail.com>
Diffstat:
8 files changed, 254 insertions(+), 63 deletions(-)
diff --git a/runtime/autoload/tutor.vim b/runtime/autoload/tutor.vim
@@ -77,46 +77,6 @@ function! tutor#TutorFolds()
endif
endfunction
-" Marks: {{{1
-
-function! tutor#ApplyMarks()
- hi! link tutorExpect Special
- if exists('b:tutor_metadata') && has_key(b:tutor_metadata, 'expect')
- let b:tutor_sign_id = 1
- for expct in keys(b:tutor_metadata['expect'])
- let lnum = eval(expct)
- call matchaddpos('tutorExpect', [lnum])
- call tutor#CheckLine(lnum)
- endfor
- endif
-endfunction
-
-function! tutor#ApplyMarksOnChanged()
- if exists('b:tutor_metadata') && has_key(b:tutor_metadata, 'expect')
- let lnum = line('.')
- if index(keys(b:tutor_metadata['expect']), string(lnum)) > -1
- call tutor#CheckLine(lnum)
- endif
- endif
-endfunction
-
-function! tutor#CheckLine(line)
- if exists('b:tutor_metadata') && has_key(b:tutor_metadata, 'expect')
- let bufn = bufnr('%')
- let ctext = getline(a:line)
- let signs = sign_getplaced(bufn, {'lnum': a:line})[0].signs
- if !empty(signs)
- call sign_unplace('', {'id': signs[0].id})
- endif
- if b:tutor_metadata['expect'][string(a:line)] == -1 || ctext ==# b:tutor_metadata['expect'][string(a:line)]
- exe "sign place ".b:tutor_sign_id." line=".a:line." name=tutorok buffer=".bufn
- else
- exe "sign place ".b:tutor_sign_id." line=".a:line." name=tutorbad buffer=".bufn
- endif
- let b:tutor_sign_id+=1
- endif
-endfunction
-
" Tutor Cmd: {{{1
function! s:Locale()
@@ -243,9 +203,9 @@ function! tutor#EnableInteractive(enable)
setlocal buftype=nofile
setlocal concealcursor+=inv
setlocal conceallevel=2
- call tutor#ApplyMarks()
+ lua require('nvim.tutor').apply_marks()
augroup tutor_interactive
- autocmd! TextChanged,TextChangedI <buffer> call tutor#ApplyMarksOnChanged()
+ autocmd! TextChanged,TextChangedI <buffer> lua require('nvim.tutor').apply_marks_on_changed()
augroup END
else
setlocal buftype<
diff --git a/runtime/lua/nvim/tutor.lua b/runtime/lua/nvim/tutor.lua
@@ -0,0 +1,80 @@
+---@class nvim.TutorMetadata
+---@field expect table<string, string|-1>
+
+---@alias nvim.TutorExtmarks table<string, string>
+
+---@type nvim.TutorExtmarks?
+vim.b.tutor_extmarks = vim.b.tutor_extmarks
+
+---@type nvim.TutorMetadata?
+vim.b.tutor_metadata = vim.b.tutor_metadata
+
+local sign_text_correct = '✓'
+local sign_text_incorrect = '✗'
+local tutor_mark_ns = vim.api.nvim_create_namespace('nvim.tutor.mark')
+local tutor_hl_ns = vim.api.nvim_create_namespace('nvim.tutor.hl')
+
+local M = {}
+
+---@param line integer 1-based
+local function check_line(line)
+ if vim.b.tutor_metadata and vim.b.tutor_metadata.expect and vim.b.tutor_extmarks then
+ local ctext = vim.fn.getline(line)
+
+ local extmarks = vim.api.nvim_buf_get_extmarks(
+ 0,
+ tutor_mark_ns,
+ { line - 1, 0 },
+ { line - 1, -1 }, -- the extmark can move to col > 0 if users insert text there
+ {}
+ )
+ for _, extmark in ipairs(extmarks) do
+ local mark_id = extmark[1]
+ local expct = vim.b.tutor_extmarks[tostring(mark_id)]
+ local expect = vim.b.tutor_metadata.expect[expct]
+ local is_correct = expect == -1 or ctext == expect
+
+ vim.api.nvim_buf_set_extmark(0, tutor_mark_ns, line - 1, 0, {
+ id = mark_id,
+ sign_text = is_correct and sign_text_correct or sign_text_incorrect,
+ sign_hl_group = is_correct and 'tutorOK' or 'tutorX',
+ -- This may be a hack. By default, all extmarks only move forward, so a line cannot contain
+ -- any extmarks that were originally created for later lines.
+ priority = tonumber(expct),
+ })
+ end
+ end
+end
+
+function M.apply_marks()
+ vim.cmd [[hi! link tutorExpect Special]]
+ if vim.b.tutor_metadata and vim.b.tutor_metadata.expect then
+ vim.b.tutor_extmarks = {}
+ for expct, _ in pairs(vim.b.tutor_metadata.expect) do
+ ---@diagnostic disable-next-line: assign-type-mismatch
+ local lnum = tonumber(expct) ---@type integer
+ vim.api.nvim_buf_set_extmark(0, tutor_hl_ns, lnum - 1, 0, {
+ line_hl_group = 'tutorExpect',
+ })
+
+ local mark_id = vim.api.nvim_buf_set_extmark(0, tutor_mark_ns, lnum - 1, 0, {})
+
+ -- Cannot edit field of a Vimscript dictionary from Lua directly, see `:h lua-vim-variables`
+ ---@type nvim.TutorExtmarks
+ local tutor_extmarks = vim.b.tutor_extmarks
+ tutor_extmarks[tostring(mark_id)] = expct
+ vim.b.tutor_extmarks = tutor_extmarks
+
+ check_line(lnum)
+ end
+ end
+end
+
+function M.apply_marks_on_changed()
+ if vim.b.tutor_metadata and vim.b.tutor_metadata.expect and vim.b.tutor_extmarks then
+ local lnum = vim.fn.line('.')
+ check_line(lnum)
+ end
+end
+
+return M
diff --git a/runtime/tutor/en/vim-01-beginner.tutor b/runtime/tutor/en/vim-01-beginner.tutor
@@ -304,7 +304,7 @@ it would be easier to simply type two d's to delete a line.
3. Now move to the fourth line.
- 4. Type `2dd`{normal} to delete two lines, then press `u`{normal} twice to undo all three lines.
+ 4. Type `2dd`{normal} to delete two lines.
1) Roses are red,
2) Mud is fun,
diff --git a/runtime/tutor/en/vim-01-beginner.tutor.json b/runtime/tutor/en/vim-01-beginner.tutor.json
@@ -12,11 +12,11 @@
"273": -1,
"292": "This line of words is cleaned up.",
"309": "1) Roses are red,",
- "310": "3) Violets are blue,",
- "311": "6) Sugar is sweet",
- "312": "7) And so are you.",
- "313": "7) And so are you.",
- "314": "7) And so are you.",
+ "310": "",
+ "311": "3) Violets are blue,",
+ "312": "",
+ "313": "",
+ "314": "6) Sugar is sweet",
"315": "7) And so are you.",
"335": "Fix the errors on this line and replace them with undo.",
"381": -1,
diff --git a/runtime/tutor/ja/vim-01-beginner.tutor.json b/runtime/tutor/ja/vim-01-beginner.tutor.json
@@ -11,13 +11,13 @@
"233": "誰かがこの行の最後を2度タイプしました。",
"272": -1,
"291": "この行の単語は綺麗になった。",
- "308": -1,
- "309": -1,
- "310": -1,
- "311": -1,
- "312": -1,
- "313": -1,
- "314": -1,
+ "308": "1) 薔薇は赤く",
+ "309": "",
+ "310": "3) 菫は青く",
+ "311": "",
+ "312": "",
+ "313": "6) 砂糖は甘く",
+ "314": "7) そして貴方も",
"335": "この行の間違いを修正し、後でそれらの修正を取り消します。",
"381": -1,
"382": -1,
diff --git a/runtime/tutor/zh/vim-01-beginner.tutor b/runtime/tutor/zh/vim-01-beginner.tutor
@@ -287,7 +287,7 @@ This ABC DE line FGHI JK LMN OP of words is Q RS TUV cleaned up.
3. 现在移动到第 4 行。
- 4. 输入 `2dd`{normal} 来删除两行,然后按两次 `u`{normal} 来恢复这三行。
+ 4. 输入 `2dd`{normal} 来删除两行。
1) Roses are red,
2) Mud is fun,
diff --git a/runtime/tutor/zh/vim-01-beginner.tutor.json b/runtime/tutor/zh/vim-01-beginner.tutor.json
@@ -14,11 +14,11 @@
"259": -1,
"276": "This line of words is cleaned up.",
"292": "1) Roses are red,",
- "293": "3) Violets are blue,",
- "294": "6) Sugar is sweet",
- "295": "7) And so are you.",
- "296": "7) And so are you.",
- "297": "7) And so are you.",
+ "293": "",
+ "294": "3) Violets are blue,",
+ "295": "",
+ "296": "",
+ "297": "6) Sugar is sweet",
"298": "7) And so are you.",
"318": "Fix the errors on this line and replace them with undo.",
"319": "Fix the errors on this line and replace them with undo.",
diff --git a/test/functional/plugin/tutor_spec.lua b/test/functional/plugin/tutor_spec.lua
@@ -16,16 +16,21 @@ describe(':Tutor', function()
command('Tutor')
screen = Screen.new(81, 30)
screen:set_default_attr_ids({
- [0] = { foreground = Screen.colors.DarkBlue, background = Screen.colors.Gray },
+ [0] = { foreground = Screen.colors.Blue4, background = Screen.colors.Grey },
[1] = { bold = true },
[2] = { underline = true, foreground = tonumber('0x0088ff') },
[3] = { foreground = Screen.colors.SlateBlue },
[4] = { bold = true, foreground = Screen.colors.Brown },
[5] = { bold = true, foreground = Screen.colors.Magenta1 },
[6] = { italic = true },
+ [7] = { foreground = tonumber('0x00ff88'), bold = true, background = Screen.colors.Grey },
+ [8] = { bold = true, foreground = Screen.colors.Blue },
+ [9] = { foreground = Screen.colors.Magenta1 },
+ [10] = { foreground = tonumber('0xff2000'), bold = true },
+ [11] = { foreground = tonumber('0xff2000'), bold = true, background = Screen.colors.Grey },
+ [12] = { foreground = tonumber('0x6a0dad') },
})
end)
-
it('applies {unix:…,win:…} transform', function()
local expected = is_os('win')
and [[
@@ -134,6 +139,152 @@ describe(':Tutor', function()
feed(':983<CR>zt')
screen:expect(expected)
end)
+
+ it("removing a line doesn't affect highlight/mark of other lines", function()
+ -- Do lesson 2.6
+ feed(':294<CR>zt')
+ screen:expect([[
+ {0: }{3:^#}{5: Lesson 2.6: OPERATING ON LINES} |
+ {0: } |
+ {0: }{1: Type }{4:dd}{1: to delete a whole line. } |
+ {0: } |
+ {0: }Due to the frequency of whole line deletion, the designers of Vi decided |
+ {0: }it would be easier to simply type two d's to delete a line. |
+ {0: } |
+ {0: } 1. Move the cursor to the second line in the phrase below. |
+ {0: } |
+ {0: } 2. Type {2:dd} to delete the line. |
+ {0: } |
+ {0: } 3. Now move to the fourth line. |
+ {0: } |
+ {0: } 4. Type {9:2}{4:dd} to delete two lines. |
+ {0: } |
+ {7:✓ }{3:1) Roses are red, }|
+ {11:✗ }{3:2) Mud is fun, }|
+ {7:✓ }{3:3) Violets are blue, }|
+ {11:✗ }{3:4) I have a car, }|
+ {11:✗ }{3:5) Clocks tell time, }|
+ {7:✓ }{3:6) Sugar is sweet }|
+ {7:✓ }{3:7) And so are you. }|
+ {0: } |
+ {0: }{3:#}{5: Lesson 2.7: THE UNDO COMMAND} |
+ {0: } |
+ {0: }{1: Press }{4:u}{1: to undo the last commands, }{4:U}{1: to fix a whole line. } |
+ {0: } |
+ {0: } 1. Move the cursor to the line below marked {10:✗} and place it on the first error.|
+ {0: } |
+ {0: } 2. Type {4:x} to delete the first unwanted character. |
+]])
+
+ feed('<Cmd>310<CR>dd<Cmd>311<CR>2dd')
+ screen:expect([[
+ {0: }{3:#}{5: Lesson 2.6: OPERATING ON LINES} |
+ {0: } |
+ {0: }{1: Type }{4:dd}{1: to delete a whole line. } |
+ {0: } |
+ {0: }Due to the frequency of whole line deletion, the designers of Vi decided |
+ {0: }it would be easier to simply type two d's to delete a line. |
+ {0: } |
+ {0: } 1. Move the cursor to the second line in the phrase below. |
+ {0: } |
+ {0: } 2. Type {2:dd} to delete the line. |
+ {0: } |
+ {0: } 3. Now move to the fourth line. |
+ {0: } |
+ {0: } 4. Type {9:2}{4:dd} to delete two lines. |
+ {0: } |
+ {7:✓ }{3:1) Roses are red, }|
+ {7:✓ }{3:3) Violets are blue, }|
+ {7:✓ }{3:^6) Sugar is sweet }|
+ {7:✓ }{3:7) And so are you. }|
+ {0: } |
+ {0: }{3:#}{5: Lesson 2.7: THE UNDO COMMAND} |
+ {0: } |
+ {0: }{1: Press }{4:u}{1: to undo the last commands, }{4:U}{1: to fix a whole line. } |
+ {0: } |
+ {0: } 1. Move the cursor to the line below marked {10:✗} and place it on the first error.|
+ {0: } |
+ {0: } 2. Type {4:x} to delete the first unwanted character. |
+ {0: } |
+ {0: } 3. Now type {4:u} to undo the last command executed. |
+ {0: } |
+ ]])
+ end)
+
+ it("inserting text at start of line doesn't affect highlight/sign", function()
+ -- Go to lesson 1.3 and make it top line in the window
+ feed('<Cmd>92<CR>zt')
+ screen:expect([[
+ {0: }{3:^#}{5: Lesson 1.3: TEXT EDITING: DELETION} |
+ {0: } |
+ {0: }{1: Press }{4:x}{1: to delete the character under the cursor. } |
+ {0: } |
+ {0: } 1. Move the cursor to the line below marked {10:✗}. |
+ {0: } |
+ {0: } 2. To fix the errors, move the cursor until it is on top of the |
+ {0: } character to be deleted. |
+ {0: } |
+ {0: } 3. Press {2:the x key} to delete the unwanted character. |
+ {0: } |
+ {0: } 4. Repeat steps 2 through 4 until the sentence is correct. |
+ {0: } |
+ {11:✗ }{3:The ccow jumpedd ovverr thhe mooon. }|
+ {0: } |
+ {0: } 5. Now that the line is correct, go on to Lesson 1.4. |
+ {0: } |
+ {0: }{1:NOTE}: As you go through this tutorial, do not try to memorize everything, |
+ {0: } your Neovim vocabulary will expand with usage. Consider returning to |
+ {0: } this tutorial periodically for a refresher. |
+ {0: } |
+ {0: }{3:#}{5: Lesson 1.4: TEXT EDITING: INSERTION} |
+ {0: } |
+ {0: }{1: Press }{12:i}{1: to insert text. } |
+ {0: } |
+ {0: } 1. Move the cursor to the first line below marked {10:✗}. |
+ {0: } |
+ {0: } 2. To make the first line the same as the second, move the cursor on top |
+ {0: } of the first character AFTER where the text is to be inserted. |
+ {0: } |
+ ]])
+ -- Go to the test line and insert text at the start of the line
+ feed('<Cmd>105<CR>iThe <Esc>')
+ -- Remove redundant characters
+ feed('fcxfdxfvxfrxfhxfox')
+ -- Remove the original "The " text (not the just-inserted one)
+ feed('^4ldw^')
+ screen:expect([[
+ {0: }{3:#}{5: Lesson 1.3: TEXT EDITING: DELETION} |
+ {0: } |
+ {0: }{1: Press }{4:x}{1: to delete the character under the cursor. } |
+ {0: } |
+ {0: } 1. Move the cursor to the line below marked {10:✗}. |
+ {0: } |
+ {0: } 2. To fix the errors, move the cursor until it is on top of the |
+ {0: } character to be deleted. |
+ {0: } |
+ {0: } 3. Press {2:the x key} to delete the unwanted character. |
+ {0: } |
+ {0: } 4. Repeat steps 2 through 4 until the sentence is correct. |
+ {0: } |
+ {7:✓ }{3:^The cow jumped over the moon. }|
+ {0: } |
+ {0: } 5. Now that the line is correct, go on to Lesson 1.4. |
+ {0: } |
+ {0: }{1:NOTE}: As you go through this tutorial, do not try to memorize everything, |
+ {0: } your Neovim vocabulary will expand with usage. Consider returning to |
+ {0: } this tutorial periodically for a refresher. |
+ {0: } |
+ {0: }{3:#}{5: Lesson 1.4: TEXT EDITING: INSERTION} |
+ {0: } |
+ {0: }{1: Press }{12:i}{1: to insert text. } |
+ {0: } |
+ {0: } 1. Move the cursor to the first line below marked {10:✗}. |
+ {0: } |
+ {0: } 2. To make the first line the same as the second, move the cursor on top |
+ {0: } of the first character AFTER where the text is to be inserted. |
+ {0: } |
+ ]])
+ end)
end)
describe(':Tutor tutor', function()