mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 12:55:20 -07:00
Compare commits
4 Commits
c150fce782
...
7442e4266c
| Author | SHA1 | Date | |
|---|---|---|---|
|
7442e4266c
|
|||
|
490f693361
|
|||
|
bacc90cd24
|
|||
|
2fbc90cf3a
|
+57
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: TASK-309
|
||||
title: Accept modified follow-up digits for multi-line sentence mining
|
||||
status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-04-27 20:06'
|
||||
updated_date: '2026-04-27 20:15'
|
||||
labels:
|
||||
- bug
|
||||
- linux
|
||||
- shortcuts
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
On Linux, `Ctrl+Shift+S` starts multi-line sentence-card mining, but the follow-up digit is not accepted and the prompt times out. Restore reliable digit capture for the multi-mine flow, including the common case where the original shortcut modifiers are still held briefly while pressing the digit.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [x] #1 `Ctrl+Shift+S` followed by a number-row digit creates a counted `mineSentenceMultiple` request instead of timing out.
|
||||
- [x] #2 Follow-up digit capture works when the user has not fully released `Ctrl`/`Shift` after the starter shortcut.
|
||||
- [x] #3 Regression coverage includes renderer session bindings and mpv plugin numeric selection.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
|
||||
Backlog MCP unavailable in this session, so this task is tracked via repo-local backlog files.
|
||||
|
||||
Implemented renderer digit extraction from `KeyboardEvent.code` for pending numeric selection, so shifted number-row events such as `Ctrl+Shift+Digit3` still dispatch count `3`. Updated the mpv plugin session-binding numeric selector to register bare digits plus the starter shortcut modifier combinations, so plugin-owned `Ctrl+Shift+S` can accept a follow-up digit before the modifiers are fully released.
|
||||
|
||||
Verification:
|
||||
|
||||
- `bun test src/renderer/handlers/keyboard.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/overlay-window.test.ts`
|
||||
- `bun run test:plugin:src`
|
||||
- `bun run changelog:lint`
|
||||
- `bun x prettier --check src/renderer/handlers/keyboard.ts src/renderer/handlers/keyboard.test.ts package.json 'changes/309-multi-mine-modified-digits.md' 'backlog/tasks/task-309 - Accept-modified-follow-up-digits-for-multi-line-sentence-mining.md'`
|
||||
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
|
||||
Restored multi-line sentence-card digit capture for the case where `Ctrl`/`Shift` are still held after `Ctrl+Shift+S`. The renderer now accepts digits by physical `Digit1`-`Digit9`/`Numpad1`-`Numpad9` code during pending numeric selection, and the mpv plugin registers the matching modified digit bindings for session-binding numeric prompts.
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
id: TASK-310
|
||||
title: Suppress N+1 highlight for kana-only candidate sentences
|
||||
status: Done
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-04-28 06:55'
|
||||
updated_date: '2026-04-28 07:04'
|
||||
labels:
|
||||
- tokenizer
|
||||
- n+1
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Reduce noisy N+1 subtitle annotations when the only unknown candidates in a sentence are kana-only hiragana or katakana words, such as mostly-kana subtitle lines where highlighting a particle/helper-like token is low value.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 N+1 annotation does not mark a kana-only unknown target when all N+1 candidates in the sentence are kana-only.
|
||||
- [x] #2 N+1 annotation continues to mark kanji or mixed-script unknown targets in otherwise eligible sentences.
|
||||
- [x] #3 A focused regression test covers the kana-only candidate case.
|
||||
- [x] #4 N+1 minimum sentence word count excludes tokens stripped by the subtitle annotation filter, so filtered grammar/noise tokens cannot satisfy minSentenceWords.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Keep the existing N+1 target eligibility guard: kana-only subtitle surfaces do not become N+1 targets.
|
||||
2. Add a focused regression in src/core/services/tokenizer/annotation-stage.test.ts proving annotation-filtered tokens do not count toward ankiConnect.nPlusOne.minSentenceWords.
|
||||
3. Verify the new regression fails before code changes.
|
||||
4. Patch src/token-merger.ts so the N+1 minimum sentence word count uses the same subtitle-annotation eligibility filter as annotation rendering, excluding filtered particles/auxiliaries/noise from the count.
|
||||
5. Re-run focused tokenizer tests, then update TASK-310 acceptance criteria and final notes.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Initial context: current token-merger has an existing surface-level kana-only guard in isNPlusOneCandidateToken, added in commit 9e4ad907. Need decide whether to broaden behavior to lookup/headword forms or verify current behavior only.
|
||||
|
||||
Implemented by treating kana-only N+1 candidates as kana-only even when their token surface includes surrounding subtitle punctuation such as ellipsis or dashes. Focused regression was red before the token-merger change: スイッチ… was marked true, then passed after the guard update. test:env initially hit an unrelated immersion-tracker active_days timing/order failure and Bun follow-on loader error; the failing test passed in isolation and the full test:env rerun passed.
|
||||
|
||||
Reopened for follow-up scope: minSentenceWords must count annotation-eligible tokens only, not tokens stripped from annotation metadata.
|
||||
|
||||
Implemented follow-up minSentenceWords behavior: unknown tokens filtered from N+1 targeting no longer contribute to sentence length; known eligible tokens and true N+1 candidates still count.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Changed N+1 sentence-length counting so minSentenceWords only counts known eligible words and actual N+1 target candidates. Unknown tokens filtered from N+1 targeting, including kana-only unknowns, no longer pad a sentence into eligibility. Existing annotation-filtered particles/auxiliaries remain excluded. Added regression coverage for the filtered unknown padding case while preserving kanji/mixed-script target behavior.
|
||||
|
||||
Verification: new regression failed before implementation; `bun test src/core/services/tokenizer/annotation-stage.test.ts -t "N\\+1"` pass; full `bun test src/core/services/tokenizer/annotation-stage.test.ts` pass; `bun test src/core/services/tokenizer.test.ts -t "N\\+1"` pass; `bun run typecheck` pass.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: shortcuts
|
||||
|
||||
- Accept follow-up number-row digits for multi-line subtitle mining even when the original shortcut modifiers are still held.
|
||||
+1
-1
@@ -45,7 +45,7 @@
|
||||
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
|
||||
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
|
||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts",
|
||||
|
||||
@@ -225,16 +225,39 @@ function M.create(ctx)
|
||||
end
|
||||
end
|
||||
|
||||
local function start_numeric_selection(action_id, timeout_ms)
|
||||
local function build_modifier_prefixes(modifiers)
|
||||
local prefixes = { "" }
|
||||
if type(modifiers) ~= "table" then
|
||||
return prefixes
|
||||
end
|
||||
|
||||
for _, modifier in ipairs(modifiers) do
|
||||
local mapped = MODIFIER_MAP[modifier]
|
||||
if mapped then
|
||||
local existing_count = #prefixes
|
||||
for index = 1, existing_count do
|
||||
prefixes[#prefixes + 1] = prefixes[index] .. mapped .. "+"
|
||||
end
|
||||
end
|
||||
end
|
||||
return prefixes
|
||||
end
|
||||
|
||||
local function start_numeric_selection(action_id, timeout_ms, starter_modifiers)
|
||||
clear_numeric_selection(false)
|
||||
local modifier_prefixes = build_modifier_prefixes(starter_modifiers)
|
||||
for digit = 1, 9 do
|
||||
local digit_string = tostring(digit)
|
||||
local name = "subminer-session-digit-" .. digit_string
|
||||
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(digit_string, name, function()
|
||||
clear_numeric_selection(false)
|
||||
invoke_cli_action(action_id, { count = digit })
|
||||
end)
|
||||
for _, prefix in ipairs(modifier_prefixes) do
|
||||
local key_name = prefix .. digit_string
|
||||
local modifier_name = prefix:gsub("[^%w]", "-")
|
||||
local name = "subminer-session-digit-" .. modifier_name .. digit_string
|
||||
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(key_name, name, function()
|
||||
clear_numeric_selection(false)
|
||||
invoke_cli_action(action_id, { count = digit })
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] =
|
||||
@@ -272,7 +295,7 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
|
||||
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms)
|
||||
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers)
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package.path = "plugin/subminer/?.lua;" .. package.path
|
||||
|
||||
local session_bindings = require("session_bindings")
|
||||
|
||||
local function assert_true(condition, message)
|
||||
if condition then
|
||||
return
|
||||
end
|
||||
error(message)
|
||||
end
|
||||
|
||||
local artifact_path = ".tmp/test-plugin-session-bindings.json"
|
||||
os.execute("mkdir -p .tmp")
|
||||
local handle = assert(io.open(artifact_path, "w"))
|
||||
handle:write("__SESSION_BINDINGS__")
|
||||
handle:close()
|
||||
|
||||
local recorded = {
|
||||
bindings = {},
|
||||
removed = {},
|
||||
async_calls = {},
|
||||
osd = {},
|
||||
}
|
||||
|
||||
local mp = {}
|
||||
|
||||
function mp.add_forced_key_binding(keys, name, fn)
|
||||
recorded.bindings[#recorded.bindings + 1] = {
|
||||
keys = keys,
|
||||
name = name,
|
||||
fn = fn,
|
||||
}
|
||||
end
|
||||
|
||||
function mp.remove_key_binding(name)
|
||||
recorded.removed[#recorded.removed + 1] = name
|
||||
end
|
||||
|
||||
function mp.add_timeout(seconds, callback)
|
||||
return {
|
||||
seconds = seconds,
|
||||
callback = callback,
|
||||
killed = false,
|
||||
kill = function(self)
|
||||
self.killed = true
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
function mp.osd_message(message)
|
||||
recorded.osd[#recorded.osd + 1] = message
|
||||
end
|
||||
|
||||
local ctx = {
|
||||
mp = mp,
|
||||
utils = {
|
||||
parse_json = function(raw)
|
||||
if raw ~= "__SESSION_BINDINGS__" then
|
||||
return nil, "unexpected artifact"
|
||||
end
|
||||
return {
|
||||
numericSelectionTimeoutMs = 3000,
|
||||
bindings = {
|
||||
{
|
||||
key = {
|
||||
code = "KeyS",
|
||||
modifiers = { "ctrl", "shift" },
|
||||
},
|
||||
actionType = "session-action",
|
||||
actionId = "mineSentenceMultiple",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
end,
|
||||
},
|
||||
state = {
|
||||
binary_path = "/tmp/subminer",
|
||||
session_binding_names = {},
|
||||
session_numeric_binding_names = {},
|
||||
session_numeric_selection = nil,
|
||||
},
|
||||
process = {
|
||||
check_binary_available = function()
|
||||
return true
|
||||
end,
|
||||
run_binary_command_async = function(args)
|
||||
recorded.async_calls[#recorded.async_calls + 1] = args
|
||||
end,
|
||||
},
|
||||
environment = {
|
||||
resolve_session_bindings_artifact_path = function()
|
||||
return artifact_path
|
||||
end,
|
||||
},
|
||||
log = {
|
||||
subminer_log = function() end,
|
||||
show_osd = function(message)
|
||||
recorded.osd[#recorded.osd + 1] = message
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
local bindings = session_bindings.create(ctx)
|
||||
assert_true(bindings.register_bindings(), "session bindings should register")
|
||||
|
||||
local starter = nil
|
||||
for _, binding in ipairs(recorded.bindings) do
|
||||
if binding.keys == "Ctrl+Shift+s" then
|
||||
starter = binding
|
||||
break
|
||||
end
|
||||
end
|
||||
assert_true(starter ~= nil, "multi-mine starter binding should be registered")
|
||||
|
||||
starter.fn()
|
||||
|
||||
local modified_digit = nil
|
||||
for _, binding in ipairs(recorded.bindings) do
|
||||
if binding.keys == "Ctrl+Shift+3" then
|
||||
modified_digit = binding
|
||||
break
|
||||
end
|
||||
end
|
||||
assert_true(modified_digit ~= nil, "numeric selection should bind Ctrl+Shift+3")
|
||||
|
||||
modified_digit.fn()
|
||||
|
||||
local call = recorded.async_calls[#recorded.async_calls]
|
||||
assert_true(call ~= nil, "modified digit should invoke CLI action")
|
||||
assert_true(call[1] == "/tmp/subminer", "CLI action should use configured binary")
|
||||
assert_true(call[2] == "--mine-sentence-count", "CLI action should mine sentence count")
|
||||
assert_true(call[3] == "3", "CLI action should pass selected count")
|
||||
|
||||
print("plugin session binding regression tests: OK")
|
||||
@@ -3086,6 +3086,27 @@ test('tokenizeSubtitle uses Yomitan word classes to classify standalone particle
|
||||
assert.equal(result.tokens?.[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle uses Yomitan word classes to classify auxiliary subclasses', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'です',
|
||||
makeDepsFromYomitanTokens(
|
||||
[{ surface: 'です', reading: 'です', headword: 'です', wordClasses: ['aux-v'] }],
|
||||
{
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: () => 10,
|
||||
getJlptLevel: () => 'N5',
|
||||
tokenizeWithMecab: async () => null,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.partOfSpeech, PartOfSpeech.bound_auxiliary);
|
||||
assert.equal(result.tokens?.[0]?.pos1, '助動詞');
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
||||
assert.equal(result.tokens?.[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle fills detailed MeCab POS when Yomitan word class supplies coarse POS', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'は',
|
||||
|
||||
@@ -359,7 +359,7 @@ function resolvePartOfSpeechFromYomitanWordClasses(wordClasses: string[]): {
|
||||
if (wordClasses.includes('prt')) {
|
||||
return { partOfSpeech: PartOfSpeech.particle, pos1: '助詞' };
|
||||
}
|
||||
if (wordClasses.includes('aux')) {
|
||||
if (wordClasses.some((wordClass) => wordClass === 'aux' || wordClass.startsWith('aux-'))) {
|
||||
return { partOfSpeech: PartOfSpeech.bound_auxiliary, pos1: '助動詞' };
|
||||
}
|
||||
if (wordClasses.some((wordClass) => wordClass.startsWith('v'))) {
|
||||
|
||||
@@ -627,6 +627,63 @@ test('annotateTokens N+1 handoff marks expected target when threshold is satisfi
|
||||
assert.equal(result[2]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens does not mark kana-only unknown target with subtitle punctuation as N+1', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '何やら',
|
||||
headword: '何やら',
|
||||
reading: 'ナニヤラ',
|
||||
pos1: '副詞',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
}),
|
||||
makeToken({
|
||||
surface: 'ボタン',
|
||||
headword: 'ボタン',
|
||||
reading: 'ボタン',
|
||||
pos1: '名詞',
|
||||
startPos: 3,
|
||||
endPos: 6,
|
||||
}),
|
||||
makeToken({
|
||||
surface: 'スイッチ…',
|
||||
headword: 'スイッチ',
|
||||
reading: 'スイッチ',
|
||||
pos1: '名詞',
|
||||
startPos: 6,
|
||||
endPos: 11,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '何やら' || text === 'ボタン',
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 3 },
|
||||
);
|
||||
|
||||
assert.equal(result[2]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens still marks kanji unknown target in otherwise eligible sentence as N+1', () => {
|
||||
const tokens = [
|
||||
makeToken({ surface: '私', headword: '私', pos1: '名詞', startPos: 0, endPos: 1 }),
|
||||
makeToken({ surface: '猫', headword: '猫', pos1: '名詞', startPos: 1, endPos: 2 }),
|
||||
makeToken({ surface: '装置…', headword: '装置', pos1: '名詞', startPos: 2, endPos: 5 }),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '私' || text === '猫',
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 3 },
|
||||
);
|
||||
|
||||
assert.equal(result[2]?.isNPlusOneTarget, true);
|
||||
});
|
||||
|
||||
test('annotateTokens N+1 minimum sentence words counts only eligible word tokens', () => {
|
||||
const tokens = [
|
||||
makeToken({ surface: '猫', headword: '猫', startPos: 0, endPos: 1 }),
|
||||
@@ -662,6 +719,32 @@ test('annotateTokens N+1 minimum sentence words counts only eligible word tokens
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens N+1 minimum sentence words excludes unknown tokens filtered from N+1 targeting', () => {
|
||||
const tokens = [
|
||||
makeToken({ surface: '私', headword: '私', pos1: '名詞', startPos: 0, endPos: 1 }),
|
||||
makeToken({ surface: '猫', headword: '猫', pos1: '名詞', startPos: 1, endPos: 2 }),
|
||||
makeToken({
|
||||
surface: 'スイッチ',
|
||||
headword: 'スイッチ',
|
||||
reading: 'スイッチ',
|
||||
pos1: '名詞',
|
||||
startPos: 2,
|
||||
endPos: 6,
|
||||
}),
|
||||
makeToken({ surface: '装置', headword: '装置', pos1: '名詞', startPos: 6, endPos: 8 }),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '私' || text === '猫',
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 4 },
|
||||
);
|
||||
|
||||
assert.equal(result[3]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens N+1 sentence word count respects source punctuation gaps omitted by Yomitan', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
@@ -713,6 +796,57 @@ test('annotateTokens N+1 sentence word count respects source punctuation gaps om
|
||||
assert.equal(result[3]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens N+1 sentence word count normalizes line breaks before gap detection', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '私',
|
||||
headword: '私',
|
||||
pos1: '名詞',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
}),
|
||||
makeToken({
|
||||
surface: '猫',
|
||||
headword: '猫',
|
||||
pos1: '名詞',
|
||||
startPos: 2,
|
||||
endPos: 3,
|
||||
}),
|
||||
makeToken({
|
||||
surface: '犬',
|
||||
headword: '犬',
|
||||
pos1: '名詞',
|
||||
startPos: 3,
|
||||
endPos: 4,
|
||||
}),
|
||||
makeToken({
|
||||
surface: 'ふざけん',
|
||||
headword: 'ふざける',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
startPos: 5,
|
||||
endPos: 9,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '私' || text === '猫' || text === '犬',
|
||||
}),
|
||||
{
|
||||
minSentenceWordsForNPlusOne: 3,
|
||||
sourceText: '私\r\n猫犬!ふざけんなよ!',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[1]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[2]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[3]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens applies configured pos1 exclusions to both frequency and N+1', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
|
||||
+36
-46
@@ -33,6 +33,11 @@ import {
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import {
|
||||
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
|
||||
} from './main/runtime/linux-mpv-fullscreen-overlay-refresh';
|
||||
import { mergeAiConfig } from './ai/config';
|
||||
|
||||
function getPasswordStoreArg(argv: string[]): string | null {
|
||||
@@ -1395,6 +1400,9 @@ const subtitleProcessingController = createSubtitleProcessingController(
|
||||
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
|
||||
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastObservedTimePos = 0;
|
||||
let cancelLinuxMpvFullscreenOverlayRefreshBurst:
|
||||
| CancelLinuxMpvFullscreenOverlayRefreshBurst
|
||||
| null = null;
|
||||
const SEEK_THRESHOLD_SECONDS = 3;
|
||||
|
||||
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||
@@ -1404,6 +1412,11 @@ function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void {
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst?.();
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
|
||||
}
|
||||
|
||||
const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => subtitlePrefetchService,
|
||||
setCurrentService: (service) => {
|
||||
@@ -1911,7 +1924,6 @@ const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as cons
|
||||
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||
const LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS = [0, 50, 150, 300, 600] as const;
|
||||
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
@@ -1919,7 +1931,6 @@ let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||
let linuxMpvFullscreenOverlayRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
|
||||
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
|
||||
@@ -1935,48 +1946,6 @@ function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||
windowsVisibleOverlayZOrderRetryTimeouts = [];
|
||||
}
|
||||
|
||||
function clearLinuxMpvFullscreenOverlayRefreshTimeouts(): void {
|
||||
for (const timeout of linuxMpvFullscreenOverlayRefreshTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts = [];
|
||||
}
|
||||
|
||||
function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(): void {
|
||||
if (process.platform !== 'linux' || !overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.hide();
|
||||
mainWindow.showInactive();
|
||||
ensureOverlayWindowLevel(mainWindow);
|
||||
}
|
||||
|
||||
function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(): void {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
for (const delayMs of LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS) {
|
||||
const refreshTimeout = setTimeout(() => {
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts = linuxMpvFullscreenOverlayRefreshTimeouts.filter(
|
||||
(timeout) => timeout !== refreshTimeout,
|
||||
);
|
||||
refreshLinuxVisibleOverlayAfterMpvFullscreenChange();
|
||||
}, delayMs);
|
||||
refreshTimeout.unref?.();
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts.push(refreshTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
|
||||
const handle = window.getNativeWindowHandle();
|
||||
return handle.length >= 8
|
||||
@@ -3146,6 +3115,10 @@ const {
|
||||
stopTexthookerService: () => texthookerService.stop(),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
},
|
||||
getMainOverlayWindow: () => overlayManager.getMainWindow(),
|
||||
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
|
||||
getModalOverlayWindow: () => overlayManager.getModalWindow(),
|
||||
@@ -3851,7 +3824,15 @@ const {
|
||||
lastObservedTimePos = time;
|
||||
},
|
||||
onFullscreenChange: () => {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst();
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst =
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst({
|
||||
overlayManager: {
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
},
|
||||
overlayVisibilityRuntime,
|
||||
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
|
||||
});
|
||||
},
|
||||
onSubtitleTrackChange: (sid) => {
|
||||
scheduleSubtitlePrefetchRefresh();
|
||||
@@ -5180,6 +5161,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
overlayManager.setMainWindow(null);
|
||||
} else {
|
||||
overlayManager.setModalWindow(null);
|
||||
@@ -5427,6 +5409,9 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
||||
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
@@ -5436,13 +5421,18 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
if (overlayManager.getVisibleOverlayVisible()) {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
} else {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
toggleVisibleOverlayHandler();
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
if (!visible) {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
calls.push('clear-windows-visible-overlay-poll'),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
|
||||
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
|
||||
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
|
||||
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
||||
@@ -42,10 +44,11 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 29);
|
||||
assert.equal(calls.length, 30);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
|
||||
destroyMainOverlayWindow: () => void;
|
||||
destroyModalOverlayWindow: () => void;
|
||||
destroyYomitanParserWindow: () => void;
|
||||
@@ -38,6 +39,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.stopSubtitleWebsocket();
|
||||
deps.stopTexthookerService();
|
||||
deps.clearWindowsVisibleOverlayForegroundPollLoop();
|
||||
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
deps.destroyMainOverlayWindow();
|
||||
deps.destroyModalOverlayWindow();
|
||||
deps.destroyYomitanParserWindow();
|
||||
|
||||
@@ -20,6 +20,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
@@ -88,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.equal(reconnectTimer, null);
|
||||
assert.equal(immersionTracker, null);
|
||||
});
|
||||
@@ -103,6 +106,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
|
||||
@@ -26,6 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
|
||||
getMainOverlayWindow: () => DestroyableWindow | null;
|
||||
clearMainOverlayWindow: () => void;
|
||||
getModalOverlayWindow: () => DestroyableWindow | null;
|
||||
@@ -67,6 +68,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts(),
|
||||
destroyMainOverlayWindow: () => {
|
||||
const window = deps.getMainOverlayWindow();
|
||||
if (!window) return;
|
||||
|
||||
@@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
getMainOverlayWindow: () => null,
|
||||
clearMainOverlayWindow: () => {},
|
||||
getModalOverlayWindow: () => null,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
|
||||
} from './linux-mpv-fullscreen-overlay-refresh';
|
||||
|
||||
test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work on linux', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst({
|
||||
overlayManager: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 200;
|
||||
while (!calls.includes('updateVisibleOverlayVisibility') && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(calls.includes('showInactive'));
|
||||
assert.ok(calls.includes('ensureOverlayWindowLevel'));
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
type LinuxMpvFullscreenOverlayWindow = {
|
||||
hide: () => void;
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
showInactive: () => void;
|
||||
};
|
||||
|
||||
export type LinuxMpvFullscreenOverlayRefreshDeps = {
|
||||
overlayManager: {
|
||||
getMainWindow: () => LinuxMpvFullscreenOverlayWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
};
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
};
|
||||
ensureOverlayWindowLevel: (window: LinuxMpvFullscreenOverlayWindow) => void;
|
||||
};
|
||||
export type CancelLinuxMpvFullscreenOverlayRefreshBurst = () => void;
|
||||
|
||||
const LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS = [0, 50, 150, 300, 600] as const;
|
||||
let linuxMpvFullscreenOverlayRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
function clearLinuxMpvFullscreenOverlayRefreshTimeouts(): void {
|
||||
for (const timeout of linuxMpvFullscreenOverlayRefreshTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts = [];
|
||||
}
|
||||
|
||||
function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
): void {
|
||||
if (process.platform !== 'linux' || !deps.overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
|
||||
const mainWindow = deps.overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.hide();
|
||||
mainWindow.showInactive();
|
||||
deps.ensureOverlayWindowLevel(mainWindow);
|
||||
}
|
||||
|
||||
export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
): CancelLinuxMpvFullscreenOverlayRefreshBurst {
|
||||
if (process.platform !== 'linux') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
for (const delayMs of LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS) {
|
||||
const refreshTimeout = setTimeout(() => {
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts = linuxMpvFullscreenOverlayRefreshTimeouts.filter(
|
||||
(timeout) => timeout !== refreshTimeout,
|
||||
);
|
||||
refreshLinuxVisibleOverlayAfterMpvFullscreenChange(deps);
|
||||
}, delayMs);
|
||||
refreshTimeout.unref?.();
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts.push(refreshTimeout);
|
||||
}
|
||||
return clearLinuxMpvFullscreenOverlayRefreshTimeouts;
|
||||
}
|
||||
|
||||
export { clearLinuxMpvFullscreenOverlayRefreshTimeouts };
|
||||
@@ -1168,6 +1168,32 @@ test('session binding: copy subtitle multiple captures follow-up digit locally',
|
||||
}
|
||||
});
|
||||
|
||||
test('session binding: mine sentence multiple captures modified follow-up digit locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.mineSentenceMultiple',
|
||||
originalKey: 'Ctrl+Shift+S',
|
||||
key: { code: 'KeyS', modifiers: ['ctrl', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'mineSentenceMultiple',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'S', code: 'KeyS', ctrlKey: true, shiftKey: true });
|
||||
testGlobals.dispatchKeydown({ key: '#', code: 'Digit3', ctrlKey: true, shiftKey: true });
|
||||
|
||||
assert.deepEqual(testGlobals.sessionActions, [
|
||||
{ actionId: 'mineSentenceMultiple', payload: { count: 3 } },
|
||||
]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -176,13 +176,17 @@ export function createKeyboardHandlers(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!/^[1-9]$/.test(e.key) || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||
const digit = /^[1-9]$/.test(e.key)
|
||||
? e.key
|
||||
: (e.code.match(/^(?:Digit|Numpad)([1-9])$/)?.[1] ?? null);
|
||||
|
||||
if (!digit) {
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const count = Number(e.key);
|
||||
const count = Number(digit);
|
||||
const actionId = pendingNumericSelection.actionId;
|
||||
cancelPendingNumericSelection(false);
|
||||
void window.electronAPI.dispatchSessionAction(actionId, { count });
|
||||
|
||||
+42
-7
@@ -177,8 +177,7 @@ export function mergeTokens(
|
||||
}
|
||||
|
||||
const result: MergedToken[] = [];
|
||||
const normalizedSourceText =
|
||||
typeof sourceText === 'string' ? sourceText.replace(/\r?\n/g, ' ').trim() : null;
|
||||
const normalizedSourceText = normalizeSourceTextForTokenOffsets(sourceText);
|
||||
let charOffset = 0;
|
||||
let sourceCursor = 0;
|
||||
let lastStandaloneToken: Token | null = null;
|
||||
@@ -191,7 +190,9 @@ export function mergeTokens(
|
||||
|
||||
for (const token of tokens) {
|
||||
const matchedStart =
|
||||
normalizedSourceText !== null ? normalizedSourceText.indexOf(token.word, sourceCursor) : -1;
|
||||
typeof normalizedSourceText === 'string'
|
||||
? normalizedSourceText.indexOf(token.word, sourceCursor)
|
||||
: -1;
|
||||
const start = matchedStart >= sourceCursor ? matchedStart : charOffset;
|
||||
const end = start + token.word.length;
|
||||
charOffset = end;
|
||||
@@ -297,9 +298,32 @@ function isKanaChar(char: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isKanaCandidateIgnorableChar(char: string): boolean {
|
||||
return /^[\s.,!?;:()[\]{}"'`、。!?…‥・「」『』()[]{}〈〉《》【】―-]$/u.test(char);
|
||||
}
|
||||
|
||||
function isKanaOnlyText(text: string): boolean {
|
||||
const normalized = text.trim();
|
||||
return normalized.length > 0 && Array.from(normalized).every((char) => isKanaChar(char));
|
||||
if (normalized.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasKana = false;
|
||||
for (const char of normalized) {
|
||||
if (isKanaChar(char)) {
|
||||
hasKana = true;
|
||||
continue;
|
||||
}
|
||||
if (!isKanaCandidateIgnorableChar(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return hasKana;
|
||||
}
|
||||
|
||||
function normalizeSourceTextForTokenOffsets(sourceText: string | undefined): string | undefined {
|
||||
return typeof sourceText === 'string' ? sourceText.replace(/\r?\n/g, ' ').trim() : undefined;
|
||||
}
|
||||
|
||||
export function isNPlusOneCandidateToken(
|
||||
@@ -362,6 +386,18 @@ function isNPlusOneWordCountToken(
|
||||
return true;
|
||||
}
|
||||
|
||||
function isNPlusOneSentenceLengthToken(
|
||||
token: MergedToken,
|
||||
pos1Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS1,
|
||||
pos2Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS2,
|
||||
): boolean {
|
||||
if (!isNPlusOneWordCountToken(token, pos1Exclusions, pos2Exclusions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return token.isKnown || isNPlusOneCandidateToken(token, pos1Exclusions, pos2Exclusions);
|
||||
}
|
||||
|
||||
function isSentenceBoundaryToken(token: MergedToken): boolean {
|
||||
if (token.partOfSpeech !== PartOfSpeech.symbol) {
|
||||
return false;
|
||||
@@ -394,8 +430,7 @@ export function markNPlusOneTargets(
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedSourceText =
|
||||
typeof sourceText === 'string' ? sourceText.replace(/\r?\n/g, ' ').trim() : undefined;
|
||||
const normalizedSourceText = normalizeSourceTextForTokenOffsets(sourceText);
|
||||
|
||||
const markedTokens = tokens.map((token) => ({
|
||||
...token,
|
||||
@@ -414,7 +449,7 @@ export function markNPlusOneTargets(
|
||||
for (let i = start; i < endExclusive; i++) {
|
||||
const token = markedTokens[i];
|
||||
if (!token) continue;
|
||||
if (isNPlusOneWordCountToken(token, pos1Exclusions, pos2Exclusions)) {
|
||||
if (isNPlusOneSentenceLengthToken(token, pos1Exclusions, pos2Exclusions)) {
|
||||
sentenceWordCount += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -302,11 +302,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
}
|
||||
|
||||
private scheduleGeometryPollBurst(): void {
|
||||
this.pollGeometry();
|
||||
for (const timeout of this.pollTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
this.pollTimeouts = [50, 150, 300].map((delayMs) => {
|
||||
this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => {
|
||||
const pollTimeout = setTimeout(() => {
|
||||
this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout);
|
||||
this.pollGeometry();
|
||||
|
||||
Reference in New Issue
Block a user