Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Core/Sources/ConverterServer/ConverterServer+Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ extension ConverterServer {
kind: .toggle,
value: .bool(Config.TypeHalfSpace().value)
),
descriptor(
key: Config.TypeHalfWidthLongVowelMark.key,
title: "ハイフンは半角を入力",
section: "入力オプション",
kind: .toggle,
value: .bool(Config.TypeHalfWidthLongVowelMark().value)
),
descriptor(
key: Config.OptionDirectFullWidthInput.key,
title: "Optionキーで直接全角英数を入力",
Expand Down Expand Up @@ -233,6 +240,8 @@ extension ConverterServer {
Config.TypeBackSlash().value = try boolSettingValue(value, key: key)
case Config.TypeHalfSpace.key:
Config.TypeHalfSpace().value = try boolSettingValue(value, key: key)
case Config.TypeHalfWidthLongVowelMark.key:
Config.TypeHalfWidthLongVowelMark().value = try boolSettingValue(value, key: key)
case Config.OptionDirectFullWidthInput.key:
Config.OptionDirectFullWidthInput().value = try boolSettingValue(value, key: key)
case Config.PunctuationStyle.key:
Expand Down
6 changes: 6 additions & 0 deletions Core/Sources/Core/Configs/BoolConfigItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ extension Config {
static let `default` = false
public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.typeHalfSpace"
}
/// 単独の長音符を半角で入力する設定
public struct TypeHalfWidthLongVowelMark: BoolConfigItem {
public init() {}
static let `default` = false
public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.typeHalfWidthLongVowelMark"
}
/// Optionキー押下時に直接全角英数を入力する設定
public struct OptionDirectFullWidthInput: BoolConfigItem {
public init() {}
Expand Down
28 changes: 28 additions & 0 deletions Core/Sources/Core/InputUtils/HyphenatedCodeCandidates.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
enum HyphenatedCodeCandidates {
/// 英数字で構成された番号・IDに、区切り文字の別表記だけを追加する。
static func variants(for reading: String) -> [String] {
var containsAlphanumeric = false
var containsSeparator = false
var halfWidthLongVowel = ""
var asciiHyphen = ""
for scalar in reading.unicodeScalars {
switch scalar.value {
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A,
0xFF10...0xFF19, 0xFF21...0xFF3A, 0xFF41...0xFF5A:
containsAlphanumeric = true
halfWidthLongVowel.unicodeScalars.append(scalar)
asciiHyphen.unicodeScalars.append(scalar)
case 0x2D, 0xFF70, 0x30FC, 0xFF0D, 0x2212:
containsSeparator = true
halfWidthLongVowel.append("ー")
asciiHyphen.append("-")
default:
return []
}
}
guard containsAlphanumeric, containsSeparator else {
return []
}
return [halfWidthLongVowel, asciiHyphen].filter { $0 != reading }
}
}
53 changes: 49 additions & 4 deletions Core/Sources/Core/InputUtils/SegmentsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,22 @@ public final class SegmentsManager {
/// テストなどの設定注入のための型。外部には設定を露出させない。
public struct Context {
public init() {}
public init(useZenzai: Bool, resourcesDirectoryURL: URL? = nil) {
public init(
useZenzai: Bool,
resourcesDirectoryURL: URL? = nil,
liveConversionEnabled: Bool? = nil,
typeHalfWidthLongVowelMark: Bool? = nil
) {
self.useZenzai = useZenzai
self.resourcesDirectoryURL = resourcesDirectoryURL
self.liveConversionEnabled = liveConversionEnabled
self.typeHalfWidthLongVowelMark = typeHalfWidthLongVowelMark
}

var useZenzai: Bool = true
var resourcesDirectoryURL: URL?
var liveConversionEnabled: Bool?
var typeHalfWidthLongVowelMark: Bool?
}

public weak var delegate: (any SegmentManagerDelegate)?
Expand All @@ -36,7 +45,11 @@ public final class SegmentsManager {
private var lastInputStyle: InputStyle = .direct

private var liveConversionEnabled: Bool {
Config.LiveConversion().value
self.context.liveConversionEnabled ?? Config.LiveConversion().value
}
private var prefersHalfWidthStandaloneLongVowelMark: Bool {
self.composingText.convertTarget == "ー"
&& (self.context.typeHalfWidthLongVowelMark ?? Config.TypeHalfWidthLongVowelMark().value)
}
private var zenzaiPersonalizationLevel: Config.ZenzaiPersonalizationLevel.Value {
Config.ZenzaiPersonalizationLevel().value
Expand Down Expand Up @@ -564,7 +577,7 @@ public final class SegmentsManager {

let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: ContextLength.conversion)
let rightSideContext = forcedRightSideContext ?? self.getCleanRightSideContext(maxCount: ContextLength.conversion)
let result = self.kanaKanjiConverter.requestCandidates(
var result = self.kanaKanjiConverter.requestCandidates(
self.composingText,
options: options(
leftSideContext: leftSideContext,
Expand All @@ -574,6 +587,36 @@ public final class SegmentsManager {
requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled
)
)
if self.prefersHalfWidthStandaloneLongVowelMark {
// 読みは全角のまま保持し、半角・全角を通常の変換候補として選択できるようにする。
let preferred = ["ー", "ー"].map { text in
Candidate(
text: text,
value: 0,
composingCount: .surfaceCount(1),
lastMid: MIDData.一般.mid,
data: [.init(word: text, ruby: "ー", cid: CIDData.記号.cid, mid: MIDData.一般.mid, value: 0)],
isLearningTarget: false
)
}
let preferredTexts = Set(preferred.map(\.text))
result.mainResults = preferred + result.mainResults.filter { !preferredTexts.contains($0.text) }
result.firstClauseResults = preferred + result.firstClauseResults.filter { !preferredTexts.contains($0.text) }
}
let codeVariants = HyphenatedCodeCandidates.variants(for: self.composingText.convertTarget)
if self.composingText.isAtEndIndex, !codeVariants.isEmpty {
let codeTexts = Set(result.mainResults.map(\.text))
let codeCandidates = codeVariants.filter { !codeTexts.contains($0) }.map { text in
Candidate(
text: text, value: -18,
composingCount: .surfaceCount(self.composingText.convertTarget.count),
lastMid: MIDData.一般.mid,
data: [.init(word: text, ruby: self.composingText.convertTarget, cid: CIDData.記号.cid, mid: MIDData.一般.mid, value: -18)],
isLearningTarget: false
)
}
result.mainResults.insert(contentsOf: codeCandidates, at: min(5, result.mainResults.count))
}
self.rawCandidates = result
}

Expand Down Expand Up @@ -1058,7 +1101,9 @@ public final class SegmentsManager {
case .none, .attachDiacritic:
return MarkedText(text: [], selectionRange: .notFound)
case .composing:
let text = if self.lastOperation == .delete {
let text = if self.prefersHalfWidthStandaloneLongVowelMark {
"ー"
} else if self.lastOperation == .delete {
// 削除のあとは常にひらがなを示す
self.composingText.convertTarget
} else if self.liveConversionEnabled,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Core
import Foundation
import KanaKanjiConverterModuleWithDefaultDictionary
import Testing

@MainActor
@Test func wholeInputShortcutIsSuppressedForEditedSegment() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let manager = SegmentsManager(kanaKanjiConverter: .withDefaultDictionary(), applicationDirectoryURL: directory,
containerURL: nil, context: .init(useZenzai: false))
manager.insertAtCursorPosition("ABー123", inputStyle: .direct)
manager.editSegment(count: -1)
manager.requestSetCandidateWindowState(visible: true)
guard case .selecting(let choices, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else {
Issue.record("Expected candidates for edited segment")
return
}
#expect(!choices.contains { $0.text == "AB-123" })
#expect(manager.convertTarget == "ABー123")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
@testable import Core
import Testing

@Suite("Hyphenated code candidates")
struct HyphenatedCodeCandidatesTests {
@Test func requestedPostalCodeHasBothSeparatorCandidates() {
#expect(HyphenatedCodeCandidates.variants(for: "457ー0067") == ["457ー0067", "457-0067"])
}

@Test(arguments: ["-", "ー", "ー", "-", "−"])
func supportsEachSeparator(_ separator: String) {
let reading = "457" + separator + "0067"
let expected = ["457ー0067", "457-0067"].filter { $0 != reading }
#expect(HyphenatedCodeCandidates.variants(for: reading) == expected)
}

@Test func preservesAlphanumericWidthAndCase() {
#expect(HyphenatedCodeCandidates.variants(for: "Ab12ーCd34") == ["Ab12ーCd34", "Ab12-Cd34"])
#expect(HyphenatedCodeCandidates.variants(for: "az-AZ") == ["azーAZ", "az-AZ"])
}

@Test func normalizesAllSeparatorsForPhoneNumbersAndIdentifiers() {
#expect(HyphenatedCodeCandidates.variants(for: "090ー1234-5678") == ["090ー1234ー5678", "090-1234-5678"])
#expect(HyphenatedCodeCandidates.variants(for: "ID-ABー12−34") == ["IDーABー12ー34", "ID-AB-12-34"])
}

@Test func omitsTheUnchangedVariant() {
#expect(HyphenatedCodeCandidates.variants(for: "457ー0067") == ["457-0067"])
#expect(HyphenatedCodeCandidates.variants(for: "457-0067") == ["457ー0067"])
}

@Test func permitsLeadingTrailingAndConsecutiveSeparators() {
#expect(HyphenatedCodeCandidates.variants(for: "ーAーー") == ["ーAーー", "-A--"])
#expect(HyphenatedCodeCandidates.variants(for: "−1") == ["ー1", "-1"])
}

@Test(arguments: [
"", "ー", "ー", "-", "-", "−", "ーー", "-ーー-−", "4570067", "ABC", "123",
"コーヒー", "こーひー", "東京都ー1", "〒457ー0067", "番号457ー0067", "457ー0067です",
"457ー0067 ", " 457ー0067", "457ー0067\n", "A_Bー1", "A.1ー2", "A/1ー2", "Aー🙂",
"٤٥٧ー0067", "④⑤⑦ー0067", "A–1", "A—1", "éー1"
])
func rejectsNonCodeOrSeparatorOnlyReadings(_ reading: String) {
#expect(HyphenatedCodeCandidates.variants(for: reading).isEmpty)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Core
import Foundation
import KanaKanjiConverterModuleWithDefaultDictionary
import Testing

@MainActor
struct HyphenatedCodeConversionTests {
@Test(arguments: [false, true], [false, true])
func postalReadingOffersHalfWidthVariants(live: Bool, enabled: Bool) throws {
for chosen in ["457ー0067", "457-0067"] {
try withManager(live: live, enabled: enabled) { manager in
for character in "457-0067" {
let code: UInt16 = character == "-" ? 27 : ["4": 21, "5": 23, "7": 26, "0": 29, "6": 22][character]!
let action = UserAction.getUserAction(eventCore: .init(modifierFlags: [], characters: String(character), charactersIgnoringModifiers: String(character), keyCode: code), inputLanguage: .japanese)
switch action {
case .number(let number): manager.insertAtCursorPosition(pieces: [number.inputPiece], inputStyle: .roman2kana)
case .input(let pieces): manager.insertAtCursorPosition(pieces: pieces, inputStyle: .roman2kana)
default: Issue.record("Unexpected input")
}
}
#expect(manager.convertTarget == "457ー0067")
try selectAndCommit(chosen, manager: manager)
}
}
}

@Test(arguments: [("090ー1234ー5678", "090ー1234ー5678"), ("ABー123", "ABー123"), ("123ー45", "123ー45")])
func phoneNumbersAndCodesUseTheSameConversion(input: String, expected: String) throws {
try withManager(live: false, enabled: true) { manager in
manager.insertAtCursorPosition(input, inputStyle: .direct)
try selectAndCommit(expected, manager: manager)
}
}

private func withManager(live: Bool, enabled: Bool, body: (SegmentsManager) throws -> Void) throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let manager = SegmentsManager(kanaKanjiConverter: .withDefaultDictionary(), applicationDirectoryURL: directory, containerURL: nil,
context: .init(useZenzai: false, liveConversionEnabled: live, typeHalfWidthLongVowelMark: enabled))
try body(manager)
}

private func selectAndCommit(_ expected: String, manager: SegmentsManager) throws {
let original = manager.convertTarget
for rich in [false, true] {
manager.update(requestRichCandidates: rich)
manager.requestSetCandidateWindowState(visible: true)
guard case .selecting(let choices, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else {
Issue.record("Expected candidates")
return
}
#expect(manager.convertTarget == original)
#expect(choices.filter { $0.text == expected }.count == 1)
#expect(choices.contains { $0.text == original })
if rich {
let row = try #require(choices.firstIndex { $0.text == expected })
manager.requestSelectingRow(row)
#expect(manager.getCurrentMarkedText(inputState: .selecting).map(\.content).joined() == expected)
let candidate = try #require(manager.selectedCandidate)
#expect(!candidate.isLearningTarget)
manager.prefixCandidateCommited(candidate, leftSideContext: "")
#expect(manager.isEmpty)
}
}
}
}
Loading