From 7fb26400a4e441480d7d5d251cbebec77fdcce52 Mon Sep 17 00:00:00 2001 From: Kan <25264536+kanlac@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:37:00 +0800 Subject: [PATCH 1/3] feat: add pyjj shuangpin scheme --- README.md | 16 +++- lua/flash-zh/char_map.lua | 54 +++++++++++ lua/flash-zh/init.lua | 89 ++++++++++------- lua/flash-zh/pinyin.lua | 45 ++++++--- lua/flash-zh/pyjj.lua | 195 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 351 insertions(+), 48 deletions(-) create mode 100644 lua/flash-zh/char_map.lua create mode 100644 lua/flash-zh/pyjj.lua diff --git a/README.md b/README.md index 15e5053..ab0d510 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # flash-zh.nvim -基于[flash.nvim](https://github.com/folke/flash.nvim)以及小鹤双拼,neovim 中文跳转插件。 +基于[flash.nvim](https://github.com/folke/flash.nvim)的 Neovim 中文跳转插件,支持小鹤双拼(默认)与拼音加加双拼。 ![iShot_2023-10-05_19 32 53](https://github.com/rainzm/flash-zh.nvim/assets/22927169/4c3ca124-0fee-48a2-b7c6-17391afe8d0e) @@ -44,6 +44,19 @@ return {{ **如果想要跳转的地方没有 label 出现,接着输入即可,和查找一样。** +### 选择双拼方案 + +插件内置两套方案: + +- `flypy`:小鹤双拼(默认) +- `pyjj`:拼音加加双拼 + +```lua +require("flash-zh").setup({ + scheme = "pyjj", +}) +``` + ### 自定义匹配字符 - 你可以覆盖、或是追加字符到默认的匹配字符集。 @@ -73,4 +86,3 @@ return {{ ## 感谢 - [hop-zh-by-flypy](https://github.com/zzhirong/hop-zh-by-flypy) - diff --git a/lua/flash-zh/char_map.lua b/lua/flash-zh/char_map.lua new file mode 100644 index 0000000..f4ce569 --- /dev/null +++ b/lua/flash-zh/char_map.lua @@ -0,0 +1,54 @@ +local flypy = require("flash-zh.flypy") + +local M = {} + +---@class FlashZhCharMap +---@field comma table +---@field escape table +---@field char1patterns table +---@field char2patterns table + +local current_name = "flypy" +---@type FlashZhCharMap +local current_map = flypy + +---@type table +local cache = { + flypy = flypy, +} + +local function build(name) + if cache[name] then + return cache[name] + end + if name == "pyjj" then + local pyjj = require("flash-zh.pyjj") + cache.pyjj = pyjj.from_flypy(flypy) + return cache.pyjj + end + error("unknown scheme: " .. tostring(name)) +end + +---@return string +function M.name() + return current_name +end + +---@return FlashZhCharMap +function M.get() + return current_map +end + +---@param name string +function M.set(name) + name = name or "flypy" + current_map = build(name) + current_name = name +end + +function M.available() + return { "flypy", "pyjj" } +end + +return M + diff --git a/lua/flash-zh/init.lua b/lua/flash-zh/init.lua index b093bc4..4250ea1 100644 --- a/lua/flash-zh/init.lua +++ b/lua/flash-zh/init.lua @@ -1,9 +1,18 @@ local flash = require("flash") -local flypy = require("flash-zh.flypy") +local char_map = require("flash-zh.char_map") local M = {} function M.jump(opts) + opts = opts or {} + + -- Allow per-jump override so users can verify schemes quickly, + -- and so it still works even if setup() was not called (common with lazy.nvim misconfig). + if opts.scheme then + require("flash-zh.char_map").set(opts.scheme) + opts.scheme = nil + end + local mode = M.mix_mode if opts.chinese_only then mode = M.zh_mode @@ -16,7 +25,7 @@ function M.jump(opts) labeler = function(_, state) require("flash-zh.labeler").new(state):update() end, - }, opts or {}) + }, opts) flash.jump(opts) end @@ -33,38 +42,45 @@ function M.mix_mode(str) end function M.zh_mode(str) + local map = char_map.get() local regexs = {} while string.len(str) > 1 do - regexs[#regexs + 1] = flypy.char2patterns[string.sub(str, 1, 2)] + local k = string.sub(str, 1, 2) + -- Be defensive: unknown keys should not crash the search. + regexs[#regexs + 1] = map.char2patterns[k] or ("[" .. k .. string.upper(k) .. "]") str = string.sub(str, 3) end if string.len(str) == 1 then - regexs[#regexs + 1] = flypy.char1patterns[str] + regexs[#regexs + 1] = map.char1patterns[str] or ("[" .. str .. string.upper(str) .. "]") end local ret = table.concat(regexs) return ret, ret end -local nodes = { - alpha = function(str) - return "[" .. str .. string.upper(str) .. "]" - end, - pinyin = function(str) - return flypy.char2patterns[str] - end, - comma = function(str) - return flypy.comma[str] - end, - singlepin = function(str) - return flypy.char1patterns[str] - end, - other = function(str) - str = flypy.escape[str] or str - return str - end, -} +local function get_nodes(map) + return { + alpha = function(str) + return "[" .. str .. string.upper(str) .. "]" + end, + pinyin = function(str) + return map.char2patterns[str] + end, + comma = function(str) + return map.comma[str] + end, + singlepin = function(str) + return map.char1patterns[str] + end, + other = function(str) + str = map.escape[str] or str + return str + end, + } +end function M.regex(parser) + local map = char_map.get() + local nodes = get_nodes(map) local regexs = {} for _, v in ipairs(parser) do regexs[#regexs + 1] = nodes[v.type](v.str) @@ -73,15 +89,16 @@ function M.regex(parser) end function M.parser(str, prefix) + local map = char_map.get() prefix = prefix or {} local firstchar = string.sub(str, 1, 1) local chars = {} - for k, _ in pairs(flypy.comma) do + for k, _ in pairs(map.comma) do table.insert(chars, k) end if firstchar == "" then return { prefix } - elseif string.match(firstchar, "%l") then + elseif string.match(firstchar, "%a") then local secondchar = string.sub(str, 2, 2) if secondchar == "" then local prefix2 = M.copy(prefix) @@ -89,7 +106,7 @@ function M.parser(str, prefix) prefix2[#prefix2 + 1] = { str = firstchar, type = "singlepin" } return { prefix, prefix2 } elseif string.match(secondchar, "%a") then - if flypy.char2patterns[firstchar .. secondchar] then + if map.char2patterns[firstchar .. secondchar] then local prefix2 = M.copy(prefix) prefix2[#prefix2 + 1] = { str = firstchar, type = "alpha" } prefix[#prefix + 1] = { str = firstchar .. secondchar, type = "pinyin" } @@ -139,6 +156,7 @@ function M.copy(table) end -- @param opts table +-- @field[opt] opts.scheme string Choose the built-in shuangpin scheme. One of: "flypy", "pyjj". -- @field opts.char_map table Char map for flypy. -- @field[opt] opts.char_map.comma table Override the default comma map. -- @field[opt] opts.char_map.append_comma table Append to the default comma map. @@ -146,9 +164,16 @@ end -- @field[opt] opts.char_map.append_char2 table Append to the default char2patterns map. function M.setup(opts) opts = opts or {} + + if opts.scheme then + char_map.set(opts.scheme) + end + if not opts.char_map then return end + + local map = char_map.get() local to_escape = "\\^$*+?.%|[]()" if opts.char_map.comma then for k, v in pairs(opts.char_map.comma) do @@ -156,7 +181,7 @@ function M.setup(opts) error("comma key must be a single character") else v = vim.fn.escape(v, to_escape) - flypy.comma[k] = "[" .. v .. "]" + map.comma[k] = "[" .. v .. "]" end end end @@ -165,9 +190,9 @@ function M.setup(opts) if #k ~= 1 then error("append_comma key must be a single character") else - local chars = flypy.comma[k] or "" + local chars = map.comma[k] or "" chars = string.sub(chars, 2, -2) .. vim.fn.escape(v, to_escape) - flypy.comma[k] = "[" .. chars .. "]" + map.comma[k] = "[" .. chars .. "]" end end end @@ -176,9 +201,9 @@ function M.setup(opts) if #k ~= 1 then error("append_char1 key must be a single character") else - local chars = flypy.char1patterns[k] or "" + local chars = map.char1patterns[k] or "" chars = string.sub(chars, 2, -2) .. vim.fn.escape(v, to_escape) - flypy.char1patterns[k] = "[" .. chars .. "]" + map.char1patterns[k] = "[" .. chars .. "]" end end end @@ -187,9 +212,9 @@ function M.setup(opts) if #k ~= 2 then error("append_char2 key must be two characters") else - local chars = flypy.char2patterns[k] or "" + local chars = map.char2patterns[k] or "" chars = string.sub(chars, 2, -2) .. vim.fn.escape(v, to_escape) - flypy.char2patterns[k] = "[" .. chars .. "]" + map.char2patterns[k] = "[" .. chars .. "]" end end end diff --git a/lua/flash-zh/pinyin.lua b/lua/flash-zh/pinyin.lua index e638785..f74a95c 100644 --- a/lua/flash-zh/pinyin.lua +++ b/lua/flash-zh/pinyin.lua @@ -1,20 +1,26 @@ -local flypy = require("flash-zh.flypy") +local char_map = require("flash-zh.char_map") local M = {} -local py_table = {} -local mt = {} -setmetatable(py_table, { __index = mt }) +local caches = {} ---@type table -function py_table:insert(char, pinyin) - if not self[char] then - self[char] = {} +local function new_py_table() + local t = {} + local mt = {} + setmetatable(t, { __index = mt }) + + function t:insert(char, pinyin) + if not self[char] then + self[char] = {} + end + table.insert(self[char], pinyin) + end + + function t:find(char) + return self[char] end - table.insert(self[char], pinyin) -end -function py_table:find(char) - return self[char] + return t end local function get_char_size(char) --获取单个字符长度 @@ -62,7 +68,10 @@ local function utf8_sub(str, startChar, numChars) --截取中文字符串 end local function init_py_table() - for k, v in pairs(flypy.char2patterns) do + local map = char_map.get() + local py_table = new_py_table() + + for k, v in pairs(map.char2patterns) do local start_char, end_char = v:find("%[(.-)%]") v = v:sub(start_char + 1, end_char - 1) for i = 1, utf8_len(v) do @@ -70,7 +79,7 @@ local function init_py_table() py_table:insert(char, k) end end - for k, v in pairs(flypy.comma) do + for k, v in pairs(map.comma) do local start_char, end_char = v:find("%[(.-)%]") v = v:sub(start_char + 1, end_char - 1) for i = 1, utf8_len(v) do @@ -78,6 +87,8 @@ local function init_py_table() py_table:insert(char, k) end end + + return py_table end local function append_to_pinyins(pinyins, suffixes) @@ -94,6 +105,13 @@ local function append_to_pinyins(pinyins, suffixes) end function M.pinyin(chars) + local name = char_map.name() + local py_table = caches[name] + if not py_table then + py_table = init_py_table() + caches[name] = py_table + end + local pinyins = {} for i = 1, utf8_len(chars) do local char = utf8_sub(chars, i, 1) @@ -116,5 +134,4 @@ function M.pinyin(chars) return result end -init_py_table() return M diff --git a/lua/flash-zh/pyjj.lua b/lua/flash-zh/pyjj.lua new file mode 100644 index 0000000..e589117 --- /dev/null +++ b/lua/flash-zh/pyjj.lua @@ -0,0 +1,195 @@ +-- Build 拼音加加( pyjj ) char map from the existing flypy dataset. +-- This keeps the dictionary/character coverage identical, only re-keys by scheme. + +local M = {} + +local function strip_brackets(pat) + -- pat is expected to be like "[...]" (as in the bundled tables). + return pat:sub(2, -2) +end + +local function wrap_brackets(s) + return "[" .. s .. "]" +end + +local function append_pat(dst, src_pat) + if not dst then + return src_pat + end + -- Merge by concatenation (duplicates are fine for a character-class). + return wrap_brackets(strip_brackets(dst) .. strip_brackets(src_pat)) +end + +-- Approximate flypy preedit_format decoding for a *single* 2-key code. +-- This is enough to turn flypy keys into canonical pinyin syllables. +local flypy_preedit_rules = { + -- Copied from rime-double-pinyin `double_pinyin_flypy.schema.yaml` preedit_format. + { "^([bpmfdtnljqx])n$", "%1iao" }, + { "^(%w)g$", "%1eng" }, + { "^(%w)q$", "%1iu" }, + { "^(%w)w$", "%1ei" }, + { "^([dtnlgkhjqxyvuirzcs])r$", "%1uan" }, + { "^(%w)t$", "%1ve" }, + { "^(%w)y$", "%1un" }, + { "^([dtnlgkhvuirzcs])o$", "%1uo" }, + { "^(%w)p$", "%1ie" }, + { "^([jqx])s$", "%1iong" }, + { "^(%w)s$", "%1ong" }, + { "^(%w)d$", "%1ai" }, + { "^(%w)f$", "%1en" }, + { "^(%w)h$", "%1ang" }, + { "^(%w)j$", "%1an" }, + { "^([gkhvuirzcs])k$", "%1uai" }, + { "^(%w)k$", "%1ing" }, + { "^([jqxnl])l$", "%1iang" }, + { "^(%w)l$", "%1uang" }, + { "^(%w)z$", "%1ou" }, + { "^([gkhvuirzcs])x$", "%1ua" }, + { "^(%w)x$", "%1ia" }, + { "^(%w)c$", "%1ao" }, + { "^([dtgkhvuirzcs])v$", "%1ui" }, + { "^(%w)b$", "%1in" }, + { "^(%w)m$", "%1ian" }, +} + +local function apply_first_gsub(s, rules) + for _, r in ipairs(rules) do + local out, n = s:gsub(r[1], r[2], 1) + if n > 0 then + return out + end + end + return s +end + +local function flypy_code_to_pinyin(code) + -- Fast path: already looks like pinyin (e.g. "ai", "an"). + -- Keep as-is; the rewrite rules below will handle the rest. + local s = apply_first_gsub(code, flypy_preedit_rules) + + -- Collapse duplicated leading vowels, e.g. "aai" -> "ai", "oou" -> "ou". + s = s:gsub("^([aoe])%1(%w)", "%1%2") + s = s:gsub("^([aoe])%1$", "%1") + + -- Initial mappings. + s = s:gsub("^v", "zh") + s = s:gsub("^i", "ch") + s = s:gsub("^u", "sh") + + -- v after jqxy => u; v after nl => ü. + s = s:gsub("^([jqxy])v", "%1u") + -- Keep output ASCII: represent ü as "v". + s = s:gsub("^([nl])v", "%1v") + s = s:gsub("ü", "v") + + return s +end + +local function pyjj_pinyin_to_codes(pinyin) + -- Keep output ASCII (Rime uses "v" internally for ü). + local base = pinyin:gsub("ü", "v"):gsub("u:", "v") + local forms = { base } + + -- derive/^([jqxy])u$/$1v/ + do + local sm = base:match("^([jqxy])u$") + if sm then + forms[#forms + 1] = sm .. "v" + end + end + + -- derive/^([aoe].*)$/o$1/ + if base:match("^[aoe]") then + forms[#forms + 1] = "o" .. base + end + + local out = {} + local seen = {} + + for _, s in ipairs(forms) do + -- xform/^([ae])(.*)$/$1$1$2/ + do + local v, rest = s:match("^([ae])(.*)$") + if v then + s = v .. v .. rest + end + end + + -- Finals (order matters; copied from rime schema speller.algebra). + s = s:gsub("iu$", "N") + s = s:gsub("[iu]a$", "B") + s = s:gsub("er$", "Q") + s = s:gsub("ing$", "Q") + s = s:gsub("[uv]an$", "C") + s = s:gsub("[uv]e$", "X") + s = s:gsub("uai$", "X") + + -- Initials. + s = s:gsub("^sh", "I") + s = s:gsub("^ch", "U") + s = s:gsub("^zh", "V") + + s = s:gsub("uo$", "O") + s = s:gsub("[uv]n$", "Z") + s = s:gsub("iong$", "Y") + s = s:gsub("ong$", "Y") + s = s:gsub("[iu]ang$", "H") + s = s:gsub("(.)en$", "%1R") + s = s:gsub("(.)eng$", "%1T") + s = s:gsub("(.)ang$", "%1G") + s = s:gsub("ian$", "J") + s = s:gsub("(.)an$", "%1F") + s = s:gsub("iao$", "K") + s = s:gsub("(.)ao$", "%1D") + s = s:gsub("(.)ai$", "%1S") + s = s:gsub("(.)ei$", "%1W") + s = s:gsub("ie$", "M") + s = s:gsub("ui$", "V") + s = s:gsub("(.)ou$", "%1P") + s = s:gsub("in$", "L") + + s = s:lower() + + -- Only keep 2-key codes (plugin expects 2 chars per syllable). + if #s == 2 and not seen[s] then + seen[s] = true + out[#out + 1] = s + end + end + + return out +end + +local function build_char1_from_char2(char2) + local acc = {} + for code, pat in pairs(char2) do + if #code == 2 then + local k = code:sub(1, 1) + acc[k] = append_pat(acc[k], pat) + end + end + return acc +end + +---@param flypy table +---@return table +function M.from_flypy(flypy) + local char2 = {} + + for code, pat in pairs(flypy.char2patterns) do + local py = flypy_code_to_pinyin(code) + local codes = pyjj_pinyin_to_codes(py) + for _, k in ipairs(codes) do + char2[k] = append_pat(char2[k], pat) + end + end + + return { + comma = flypy.comma, + escape = flypy.escape, + char2patterns = char2, + char1patterns = build_char1_from_char2(char2), + } +end + +return M From 7d2d419d6cc07ef101172ed231d345e8d91779ac Mon Sep 17 00:00:00 2001 From: Kan <25264536+kanlac@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:54:20 +0800 Subject: [PATCH 2/3] fix: case-insensitive shuangpin input --- lua/flash-zh/init.lua | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lua/flash-zh/init.lua b/lua/flash-zh/init.lua index 4250ea1..c84e4ef 100644 --- a/lua/flash-zh/init.lua +++ b/lua/flash-zh/init.lua @@ -45,13 +45,16 @@ function M.zh_mode(str) local map = char_map.get() local regexs = {} while string.len(str) > 1 do - local k = string.sub(str, 1, 2) + local orig = string.sub(str, 1, 2) + local k = orig:lower() -- Be defensive: unknown keys should not crash the search. - regexs[#regexs + 1] = map.char2patterns[k] or ("[" .. k .. string.upper(k) .. "]") + regexs[#regexs + 1] = map.char2patterns[k] or ("[" .. orig:lower() .. orig:upper() .. "]") str = string.sub(str, 3) end if string.len(str) == 1 then - regexs[#regexs + 1] = map.char1patterns[str] or ("[" .. str .. string.upper(str) .. "]") + local orig = str + local k = orig:lower() + regexs[#regexs + 1] = map.char1patterns[k] or ("[" .. orig:lower() .. orig:upper() .. "]") end local ret = table.concat(regexs) return ret, ret @@ -60,7 +63,7 @@ end local function get_nodes(map) return { alpha = function(str) - return "[" .. str .. string.upper(str) .. "]" + return "[" .. str:lower() .. str:upper() .. "]" end, pinyin = function(str) return map.char2patterns[str] @@ -103,13 +106,14 @@ function M.parser(str, prefix) if secondchar == "" then local prefix2 = M.copy(prefix) prefix[#prefix + 1] = { str = firstchar, type = "alpha" } - prefix2[#prefix2 + 1] = { str = firstchar, type = "singlepin" } + prefix2[#prefix2 + 1] = { str = firstchar:lower(), type = "singlepin" } return { prefix, prefix2 } elseif string.match(secondchar, "%a") then - if map.char2patterns[firstchar .. secondchar] then + local code = (firstchar .. secondchar):lower() + if map.char2patterns[code] then local prefix2 = M.copy(prefix) prefix2[#prefix2 + 1] = { str = firstchar, type = "alpha" } - prefix[#prefix + 1] = { str = firstchar .. secondchar, type = "pinyin" } + prefix[#prefix + 1] = { str = code, type = "pinyin" } local str2 = string.sub(str, 2, -1) str = string.sub(str, 3, -1) return M.merge_table(M.parser(str, prefix), M.parser(str2, prefix2)) From 08826bac5b9f33bd4cc656bddb28ae980fe52f96 Mon Sep 17 00:00:00 2001 From: Kan <25264536+kanlac@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:30:25 +0800 Subject: [PATCH 3/3] feat: add remote() with zh shuangpin mode --- lua/flash-zh/init.lua | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lua/flash-zh/init.lua b/lua/flash-zh/init.lua index c84e4ef..25b531b 100644 --- a/lua/flash-zh/init.lua +++ b/lua/flash-zh/init.lua @@ -29,6 +29,31 @@ function M.jump(opts) flash.jump(opts) end +function M.remote(opts) + opts = opts or {} + + -- Same behavior as jump(), but performs a remote operation (operator-pending). + if opts.scheme then + require("flash-zh.char_map").set(opts.scheme) + opts.scheme = nil + end + + local mode = M.mix_mode + if opts.chinese_only then + mode = M.zh_mode + end + opts = vim.tbl_deep_extend("force", { + labels = "asdfghjklqwertyuiopzxcvbnm", + search = { + mode = mode, + }, + labeler = function(_, state) + require("flash-zh.labeler").new(state):update() + end, + }, opts) + flash.remote(opts) +end + function M.mix_mode(str) local all_possible_splits = M.parser(str) local regexs = { [[\(]] }