Skip to content

Commit 630d5a5

Browse files
authored
Merge pull request #34 from mad-p/feature/stroke_stats
ストローク統計を実装
2 parents 2757682 + ec8a8d1 commit 630d5a5

13 files changed

Lines changed: 494 additions & 7 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ work/
2424
.vscode
2525
release/
2626
/.claude
27+
/.envrc

Claude.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,42 @@ log stream --predicate 'process == "MacTcode"'
120120
- 基本文字、部首変換、交ぜ書き変換、機能実行をカウント
121121
- 定期的に`tc-record.txt`に出力(デフォルト1200秒間隔)
122122

123+
### このセッションで行った変更(要約・2026-02-21)
124+
125+
以下はこの作業セッションで実装/追加した内容の短いサマリです。将来の解析やデバッグ時に参照してください。
126+
127+
- InputStats の拡張
128+
- ストローク(T‑Code基本キー)単位の頻度統計を追加しました。
129+
- 追加メソッド: `recordStroke(key:)`, `recordNonStrokeEvent()`, `resetStrokeStats()`, `writeStrokeStatsToFile()`, `loadStrokeStatsMaybe()`
130+
- 内部データ構造: `keyCount[40]`, `basicCharCount[1600]`, `bigram[1600]`, `panes`, `alternation`
131+
- 書き出しは既存の統計同期タイミング(`tc-record.txt` と同時)で行います。累積保存です(リセットしない)。
132+
133+
- 設定変更
134+
- 新しい設定フラグを追加: `system.strokeStatsEnabled`(デフォルト: `true`)。
135+
- このフラグが `false` の場合、ストローク統計の収集および `stroke-stats.json` の読み書きは行われません。
136+
137+
- 記録フックと連続性ルール
138+
- 記録は Option A(実際に basic と判定された箇所)を採用し、`TcodeMode``.text` 分岐で `Translator.strToKey` が 0..39 を返す文字を `recordStroke` で記録します。
139+
- 連続性を断つ(バイグラムを切る)イベント: 部首/交ぜ書き受容、機能実行、モード切替、候補確定、PendingKakutei の受容、非基本キー入力など。
140+
141+
- コードの主な変更箇所
142+
- `MacTcode/InputStats.swift` — ストローク統計実装
143+
- `MacTcode/UserConfigs.swift``strokeStatsEnabled` を追加(デフォルト true)
144+
- `MacTcode/Tcode/TcodeMode.swift``.text` 分岐で `recordStroke` 呼び出し
145+
- `MacTcode/Tcode/TcodeInputController.swift` — push/pop/deactivate/candidateSelected で `recordNonStrokeEvent` 呼び出し
146+
- `MacTcode/Mode/PendingKakuteiMode.swift` — accept() で連続性断ち
147+
148+
- テストとドキュメント
149+
- 単体テスト追加: `MacTcodeTests/StrokeStatsTests.swift`(ストローク記録、連続性切断、無効化挙動の検証)
150+
- ドキュメント追加/更新:
151+
- `STROKE_STATS.md``stroke-stats.json` のフォーマット仕様
152+
- `README.md`:統計セクションにストローク統計の説明追記
153+
- `ConfigParams.md``system.strokeStatsEnabled` の説明追記
154+
155+
重要: これら変更は既存のビルドとテストスイートで検証済み(Debug ビルド成功、ユニットテスト実行成功)。
156+
157+
必要に応じて、`STROKE_STATS.md` に記載した JSON を読み取る解析スクリプト(Python など)を追加できます。希望があれば実装します。
158+
123159
## 重要なパターン
124160

125161
1. **プロトコル指向**: `Mode`, `Controller`, `Client`などで責務を明確化

ConfigParams.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,9 @@ MacTcodeは`config.json`形式の設定ファイルを使用してカスタマ
156156
- `"nul"`: NUL文字(\0)を送信
157157
- `"empty"`: 空文字列("")を送信
158158
- 例: `{"com.google.android.studio": "nul", "com.jetbrains.intellij": "nul"}`
159-
- **`syncStatsInterval`**: 統計情報をファイルに出力する間隔(秒単位)
159+
- **`syncStatsInterval`**: 統計出力の間隔(秒単位)
160160
- デフォルト値: 1200(20分)
161161
- 0に設定すると統計ファイルの出力が無効になります
162-
- 統計情報は入力メソッド切り替え時やアプリケーション終了時に自動的に保存されます
163162
- 詳細はREADME.mdの「統計情報の記録」セクションを参照してください
164163
- **`cancelPeriod`**: 変換確定後のキャンセル可能期間(秒、0.1-10.0)
165164
- 変換確定後、この期間内であればDeleteキーやControl-g、Escapeキーで変換をキャンセルして読みに戻すことができます

MacTcode.xcodeproj/project.pbxproj

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
9D7C3CA32ED41C57009690B8 /* LineModeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D7C3CA22ED41C53009690B8 /* LineModeMap.swift */; };
1818
9D7C3CA52ED41E49009690B8 /* LineModeActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D7C3CA42ED41E3E009690B8 /* LineModeActions.swift */; };
1919
9D7C3CA72ED41F46009690B8 /* LineMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D7C3CA62ED41F44009690B8 /* LineMode.swift */; };
20+
9D8530172F4984F800740496 /* StrokeStatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8530162F4984F800740496 /* StrokeStatsTests.swift */; };
2021
9D868A012C05F98700128D48 /* main@2x.tiff in Resources */ = {isa = PBXBuildFile; fileRef = 9D868A002C05F98700128D48 /* main@2x.tiff */; };
2122
9D868FF32C706AB200293645 /* ContextClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D868FF22C706AB200293645 /* ContextClientTests.swift */; };
2223
9D8BFEC82C0FA9DA00B99BA2 /* ZenkakuModeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8BFEC72C0FA9DA00B99BA2 /* ZenkakuModeTest.swift */; };
@@ -100,6 +101,7 @@
100101
9D7C3CA22ED41C53009690B8 /* LineModeMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineModeMap.swift; sourceTree = "<group>"; };
101102
9D7C3CA42ED41E3E009690B8 /* LineModeActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineModeActions.swift; sourceTree = "<group>"; };
102103
9D7C3CA62ED41F44009690B8 /* LineMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineMode.swift; sourceTree = "<group>"; };
104+
9D8530162F4984F800740496 /* StrokeStatsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrokeStatsTests.swift; sourceTree = "<group>"; };
103105
9D868A002C05F98700128D48 /* main@2x.tiff */ = {isa = PBXFileReference; lastKnownFileType = image.tiff; path = "main@2x.tiff"; sourceTree = "<group>"; };
104106
9D868FF22C706AB200293645 /* ContextClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextClientTests.swift; sourceTree = "<group>"; };
105107
9D8BFEC72C0FA9DA00B99BA2 /* ZenkakuModeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZenkakuModeTest.swift; sourceTree = "<group>"; };
@@ -265,6 +267,7 @@
265267
9D8C8DC82C01ACF1003D8A43 /* MacTcodeTests */ = {
266268
isa = PBXGroup;
267269
children = (
270+
9D8530162F4984F800740496 /* StrokeStatsTests.swift */,
268271
9D7C3C9A2ECB52CB009690B8 /* ControllerSpy.swift */,
269272
9D8C8DC92C01ACF1003D8A43 /* KeymapTests.swift */,
270273
9D0F3CE42C04DEAC005A0CAC /* BushuTests.swift */,
@@ -555,6 +558,7 @@
555558
buildActionMask = 2147483647;
556559
files = (
557560
9D8C8DCA2C01ACF1003D8A43 /* KeymapTests.swift in Sources */,
561+
9D8530172F4984F800740496 /* StrokeStatsTests.swift in Sources */,
558562
9DEA6D3E2C0B40320005BB18 /* RecentTextTests.swift in Sources */,
559563
9D868FF32C706AB200293645 /* ContextClientTests.swift in Sources */,
560564
9DEA6D402C0B6C680005BB18 /* TranslationTests.swift in Sources */,
@@ -742,7 +746,7 @@
742746
"@executable_path/../Frameworks",
743747
);
744748
MACOSX_DEPLOYMENT_TARGET = 11.0;
745-
MARKETING_VERSION = 0.16.0;
749+
MARKETING_VERSION = 0.17.0;
746750
ONLY_ACTIVE_ARCH = NO;
747751
PRODUCT_BUNDLE_IDENTIFIER = "jp.mad-p.inputmethod.MacTcode";
748752
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -779,7 +783,7 @@
779783
"@executable_path/../Frameworks",
780784
);
781785
MACOSX_DEPLOYMENT_TARGET = 11.0;
782-
MARKETING_VERSION = 0.16.0;
786+
MARKETING_VERSION = 0.17.0;
783787
PRODUCT_BUNDLE_IDENTIFIER = "jp.mad-p.inputmethod.MacTcode";
784788
PRODUCT_NAME = "$(TARGET_NAME)";
785789
SWIFT_EMIT_LOC_STRINGS = YES;

MacTcode/InputStats.swift

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,27 @@ class InputStats {
1212
private var functionCount = 0
1313
private var totalActionCount = 0
1414

15+
// ストローク統計 (T-Code基本キーのみ)
16+
// per-key count (0..39)
17+
private var keyCount: [Int] = Array(repeating: 0, count: nKeys)
18+
// basic character frequency (1st*40 + 2nd -> 0..1599)
19+
private var basicCharCount: [Int] = Array(repeating: 0, count: nKeys * nKeys)
20+
// bigram (same shape as basicCharCount)
21+
private var bigramCount: [Int] = Array(repeating: 0, count: nKeys * nKeys)
22+
// panes frequency: "RL","RR","LL","LR"
23+
private var panes: [String: Int] = ["RL": 0, "RR": 0, "LL": 0, "LR": 0]
24+
// alternation counts
25+
private var alternation: [String: Int] = ["alternate": 0, "consecutive": 0, "first": 0]
26+
27+
// state for continuity
28+
private var lastStrokeKey: Int? = nil
29+
private var lastStrokePane: String? = nil // "L" or "R"
30+
1531
private let queue = DispatchQueue(label: "jp.mad-p.inputmethods.MacTcode.inputstats", attributes: .concurrent)
1632

1733
private init() {
34+
// 初期化時に保存済みの stroke-stats.json を読み込む
35+
loadStrokeStatsMaybe()
1836
}
1937

2038
/// 基本文字入力のカウントを増やす
@@ -30,13 +48,17 @@ class InputStats {
3048
func incrementBushuCount() {
3149
queue.async(flags: .barrier) {
3250
self.bushuCount += 1
51+
// continuity break
52+
self.recordNonStrokeEventInternal()
3353
}
3454
}
3555

3656
/// 交ぜ書き変換のカウントを増やす
3757
func incrementMazegakiCount() {
3858
queue.async(flags: .barrier) {
3959
self.mazegakiCount += 1
60+
// continuity break
61+
self.recordNonStrokeEventInternal()
4062
}
4163
}
4264

@@ -45,6 +67,135 @@ class InputStats {
4567
queue.async(flags: .barrier) {
4668
self.functionCount += 1
4769
self.totalActionCount += 1
70+
// continuity break
71+
self.recordNonStrokeEventInternal()
72+
}
73+
}
74+
75+
// MARK: - Stroke statistics API
76+
77+
/// ストローク(T-Code基本文字)を記録する
78+
/// - Parameter key1, key2: 0..(nKeys-1)
79+
func recordBasicStroke(key1: Int, key2: Int) {
80+
guard UserConfigs.i.system.strokeStatsEnabled else { return }
81+
guard (0..<nKeys).contains(key1) else { return }
82+
guard (0..<nKeys).contains(key2) else { return }
83+
queue.async(flags: .barrier) {
84+
let idx = key1 * nKeys + key2
85+
self.basicCharCount[idx] += 1
86+
let pane1 = key1 % 10 < 5 ? "L" : "R"
87+
let pane2 = key2 % 10 < 5 ? "L" : "R"
88+
let pair = pane1 + pane2
89+
if self.panes[pair] != nil {
90+
self.panes[pair]! += 1
91+
}
92+
}
93+
}
94+
95+
/// ストローク(T-Code基本キー)を記録する
96+
/// - Parameter key: 0..(nKeys-1)
97+
func recordStroke(key: Int) {
98+
guard UserConfigs.i.system.strokeStatsEnabled else { return }
99+
guard (0..<nKeys).contains(key) else { return }
100+
queue.async(flags: .barrier) {
101+
self.keyCount[key] += 1
102+
// panes
103+
let pane = (key % 10) < 5 ? "L" : "R"
104+
if let last = self.lastStrokeKey {
105+
// bigram
106+
let idx = last * nKeys + key
107+
self.bigramCount[idx] += 1
108+
// panes pair
109+
if let lastPane = self.lastStrokePane {
110+
// alternation
111+
if lastPane == pane {
112+
self.alternation["consecutive"]! += 1
113+
} else {
114+
self.alternation["alternate"]! += 1
115+
}
116+
}
117+
} else {
118+
// first hit
119+
self.alternation["first"]! += 1
120+
}
121+
// update last
122+
self.lastStrokeKey = key
123+
self.lastStrokePane = pane
124+
}
125+
}
126+
127+
/// 非ストロークイベントを記録して連続性を断つ
128+
func recordNonStrokeEvent() {
129+
guard UserConfigs.i.system.strokeStatsEnabled else { return }
130+
queue.async(flags: .barrier) {
131+
self.recordNonStrokeEventInternal()
132+
}
133+
}
134+
135+
// internal: assumes barrier queue
136+
private func recordNonStrokeEventInternal() {
137+
self.lastStrokeKey = nil
138+
self.lastStrokePane = nil
139+
}
140+
141+
/// ストローク統計をリセット(メモリ内)
142+
func resetStrokeStats() {
143+
queue.async(flags: .barrier) {
144+
self.keyCount = Array(repeating: 0, count: nKeys)
145+
self.basicCharCount = Array(repeating: 0, count: nKeys * nKeys)
146+
self.bigramCount = Array(repeating: 0, count: nKeys * nKeys)
147+
self.panes = ["RL": 0, "RR": 0, "LL": 0, "LR": 0]
148+
self.alternation = ["alternate": 0, "consecutive": 0, "first": 0]
149+
self.lastStrokeKey = nil
150+
self.lastStrokePane = nil
151+
}
152+
}
153+
154+
// MARK: - Persistence (JSON)
155+
156+
private func strokeStatsFileURL() -> URL {
157+
return UserConfigs.i.configFileURL("stroke-stats.json")
158+
}
159+
160+
func loadStrokeStatsMaybe() {
161+
guard UserConfigs.i.system.strokeStatsEnabled else { return }
162+
let fileURL = strokeStatsFileURL()
163+
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
164+
queue.async(flags: .barrier) {
165+
do {
166+
let data = try Data(contentsOf: fileURL)
167+
if let obj = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
168+
if let k = obj["keyCount"] as? [Int], k.count == nKeys { self.keyCount = k }
169+
if let b = obj["basicCharCount"] as? [Int], b.count == nKeys * nKeys { self.basicCharCount = b }
170+
if let bg = obj["bigram"] as? [Int], bg.count == nKeys * nKeys { self.bigramCount = bg }
171+
if let p = obj["panes"] as? [String: Int] { self.panes = p }
172+
if let a = obj["alternation"] as? [String: Int] { self.alternation = a }
173+
}
174+
} catch {
175+
Log.i("Failed to load stroke-stats: \(error)")
176+
}
177+
}
178+
}
179+
180+
func writeStrokeStatsToFile() {
181+
guard UserConfigs.i.system.strokeStatsEnabled else { return }
182+
queue.sync {
183+
let fileURL = strokeStatsFileURL()
184+
var obj: [String: Any] = [:]
185+
obj["keyCount"] = self.keyCount
186+
obj["basicCharCount"] = self.basicCharCount
187+
obj["bigram"] = self.bigramCount
188+
obj["panes"] = self.panes
189+
obj["alternation"] = self.alternation
190+
obj["lastUpdated"] = ISO8601DateFormatter().string(from: Date())
191+
192+
do {
193+
let data = try JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys])
194+
try data.write(to: fileURL)
195+
} catch {
196+
Log.i("Failed to write stroke-stats: \(error)")
197+
}
198+
Log.i("★Stroke statistics written")
48199
}
49200
}
50201

@@ -109,6 +260,9 @@ class InputStats {
109260
}
110261
}
111262

263+
// 同期タイミングでstroke-statsも書き出す(累積)
264+
self.writeStrokeStatsToFile()
265+
112266
Log.i("★Statistics written: \(statsLine.trimmingCharacters(in: .whitespacesAndNewlines))")
113267
lastSyncDate = Date()
114268
basicCount = 0

MacTcode/Mode/PendingKakuteiMode.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ class PendingKakuteiMode: Mode {
7373
} else {
7474
Log.i("accepted \(yomiString) -> \(kakuteiString); parameter = nil")
7575
}
76+
// 受容は連続性を断つ
77+
InputStats.i.recordNonStrokeEvent()
7678
uninstall()
7779
return onAccepted(parameter, inputEvent)
7880
}

MacTcode/Tcode/TcodeInputController.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ class TcodeInputController: IMKInputController, Controller {
3939
Log.i("deactivate: accepting pendingKakutei")
4040
_ = pending.accept()
4141
}
42+
// continuity break
43+
InputStats.i.recordNonStrokeEvent()
4244
InputStats.i.writeStatsToFileMaybe()
4345
super.deactivateServer(sender)
4446
}
@@ -138,6 +140,8 @@ class TcodeInputController: IMKInputController, Controller {
138140
if let modeWithCandidates = mode as? ModeWithCandidates {
139141
if self.client() != nil {
140142
modeWithCandidates.candidateSelected(candidateString, client: wrapClient())
143+
// 候補確定は連続性を断つ
144+
InputStats.i.recordNonStrokeEvent()
141145
} else {
142146
Log.i("*** TcodeInputController.candidateSelected: client is not IMKTextInput???")
143147
}
@@ -167,10 +171,14 @@ class TcodeInputController: IMKInputController, Controller {
167171
Log.i("TcodeInputController.pushMode: \(mode)")
168172
modeStack = [mode] + modeStack
169173
mode.setController(self)
174+
// モード切替はストローク連続性を断つ
175+
InputStats.i.recordNonStrokeEvent()
170176
}
171177
func popMode(_ mode: Mode) {
172178
if let index = modeStack.firstIndex(where: { $0 === mode }) {
173179
modeStack.remove(at: index)
180+
// モード切替は連続性を断つ
181+
InputStats.i.recordNonStrokeEvent()
174182
}
175183
}
176184
func getActiveMode(of targetClass: Mode.Type) -> Mode? {

MacTcode/Tcode/TcodeMode.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ class TcodeMode: Mode, MultiStroke {
7070
resetPending()
7171
client.insertText(string, replacementRange: NSRange(location: NSNotFound, length: NSNotFound))
7272

73+
// ここでストローク統計を記録
74+
// 基本キー2打鍵で入力された場合に限り、統計情報を記録する
75+
// NOTE: バックスラッシュによる記号入力は2打鍵でも基本キー以外も使うので除外
76+
// NOTE: 3ストローク以上に対応するときは修正が必要
77+
if seq.count == 2 {
78+
if let ev0 = seq[0].text, let ev1 = seq[1].text,
79+
let k1 = Translator.strToKey(ev0), let k2 = Translator.strToKey(ev1) {
80+
InputStats.i.recordBasicStroke(key1: k1, key2: k2)
81+
InputStats.i.recordStroke(key: k1)
82+
InputStats.i.recordStroke(key: k2)
83+
}
84+
}
85+
7386
// 自動部首変換を試みる
7487
if Bushu.i.tryAutoBushu(client: client, controller: controller!) {
7588
InputStats.i.incrementBushuCount()

MacTcode/UserConfigs.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ class UserConfigs {
221221
let keyboardLayoutMapping: [String]
222222
let syncStatsInterval: Int
223223
let cancelPeriod: Double
224+
// ストローク統計を有効にするかどうか
225+
let strokeStatsEnabled: Bool
224226

225227
static let `default` = SystemConfig(
226228
recentTextMaxLength: 20,
@@ -236,7 +238,8 @@ class UserConfigs {
236238
";", "q", "j", "k", "x", "b", "m", "w", "v", "z"
237239
],
238240
syncStatsInterval: 1200,
239-
cancelPeriod: 1.5
241+
cancelPeriod: 1.5,
242+
strokeStatsEnabled: true
240243
)
241244
}
242245

0 commit comments

Comments
 (0)