diff --git a/Core/Sources/ConverterServer/ConverterServer+Settings.swift b/Core/Sources/ConverterServer/ConverterServer+Settings.swift index e5229eff..066fa5ef 100644 --- a/Core/Sources/ConverterServer/ConverterServer+Settings.swift +++ b/Core/Sources/ConverterServer/ConverterServer+Settings.swift @@ -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キーで直接全角英数を入力", @@ -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: diff --git a/Core/Sources/Core/Configs/BoolConfigItem.swift b/Core/Sources/Core/Configs/BoolConfigItem.swift index 5f92529e..fcd98303 100644 --- a/Core/Sources/Core/Configs/BoolConfigItem.swift +++ b/Core/Sources/Core/Configs/BoolConfigItem.swift @@ -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() {} diff --git a/Core/Sources/Core/InputUtils/HyphenatedCodeCandidates.swift b/Core/Sources/Core/InputUtils/HyphenatedCodeCandidates.swift new file mode 100644 index 00000000..ee8f5ca0 --- /dev/null +++ b/Core/Sources/Core/InputUtils/HyphenatedCodeCandidates.swift @@ -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 } + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 3188ca36..c6d0bf84 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -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)? @@ -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 @@ -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, @@ -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 } @@ -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, diff --git a/Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift b/Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift new file mode 100644 index 00000000..f3c9603d --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift @@ -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") +} diff --git a/Core/Tests/CoreTests/InputUtilsTests/HyphenatedCodeCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/HyphenatedCodeCandidatesTests.swift new file mode 100644 index 00000000..6b625cd7 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/HyphenatedCodeCandidatesTests.swift @@ -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) + } +} diff --git a/Core/Tests/CoreTests/InputUtilsTests/HyphenatedCodeConversionTests.swift b/Core/Tests/CoreTests/InputUtilsTests/HyphenatedCodeConversionTests.swift new file mode 100644 index 00000000..48279d6f --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/HyphenatedCodeConversionTests.swift @@ -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) + } + } + } +} diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerLongVowelMarkTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerLongVowelMarkTests.swift new file mode 100644 index 00000000..9c106749 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerLongVowelMarkTests.swift @@ -0,0 +1,271 @@ +import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@MainActor +@Suite("Standalone half-width long vowel mark conversion") +struct SegmentsManagerLongVowelMarkTests { + private func withManager( + enabled: Bool = true, + live: 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 input(_ text: String, manager: SegmentsManager, style: InputStyle = .roman2kana) { + for character in text { + let event = KeyEventCore( + modifierFlags: [], characters: String(character), charactersIgnoringModifiers: String(character), keyCode: 27 + ) + if case .input(let pieces) = UserAction.getUserAction(eventCore: event, inputLanguage: .japanese) { + manager.insertAtCursorPosition(pieces: pieces, inputStyle: style) + } else { + Issue.record("Expected a text input action") + } + } + } + + private func marked(_ manager: SegmentsManager, state: InputState = .composing) -> String { + manager.getCurrentMarkedText(inputState: state).map(\.content).joined() + } + + private func route(_ action: UserAction, state: InputState = .composing, live: Bool) -> (ClientAction, ClientActionCallback) { + state.event( + eventCore: .init(modifierFlags: [], characters: nil, charactersIgnoringModifiers: nil, keyCode: 0), + userAction: action, inputLanguage: .japanese, liveConversionEnabled: live, + enableDebugWindow: false, enableSuggestion: false + ) + } + + private func candidates(_ manager: SegmentsManager) throws -> [Candidate] { + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let candidates, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("Expected conversion candidate selection") + return [] + } + return candidates + } + + @Test(arguments: [false, true]) + func inputStaysMarkedUntilEnter(live: Bool) throws { + try withManager(live: live) { manager in + let (action, callback) = route(.input([.character("ー")]), state: .none, live: live) + guard case .appendPieceToMarkedText = action, case .transition(.composing) = callback else { + Issue.record("Input must start composition instead of committing immediately") + return + } + input("-", manager: manager) + #expect(!manager.isEmpty) + #expect(manager.convertTarget == "ー") + #expect(marked(manager) == "ー") + if live { + guard case .hidden = manager.getCurrentCandidateWindow(inputState: .composing) else { + Issue.record("Live conversion must keep the composing candidate window hidden") + return + } + } else { + guard case .composing(let candidates, _) = manager.getCurrentCandidateWindow(inputState: .composing) else { + Issue.record("Non-live conversion must expose the composing candidate") + return + } + #expect(candidates.first?.text == "ー") + } + let (enter, transition) = route(.enter, live: live) + guard case .commitMarkedText = enter, case .transition(.none) = transition else { + Issue.record("Enter must commit the existing marked text") + return + } + #expect(manager.commitMarkedText(inputState: .composing) == "ー") + #expect(manager.isEmpty) + } + } + + @Test(arguments: [false, true], ["ー", "ー"]) + func spaceAllowsBothWidthsAndEnterCommitsSelection(live: Bool, selectedText: String) throws { + try withManager(live: live) { manager in + input("-", manager: manager) + let (space, transition) = route(.space(prefersFullWidthWhenInput: false), live: live) + if live { + guard case .enterCandidateSelectionMode = space, case .transition(.selecting) = transition else { + Issue.record("Live Space must enter candidate selection") + return + } + } else { + guard case .enterFirstCandidatePreviewMode = space, case .transition(.previewing) = transition else { + Issue.record("Non-live Space must preview the first candidate") + return + } + manager.insertCompositionSeparator(inputStyle: .roman2kana) + manager.requestSetCandidateWindowState(visible: false) + #expect(marked(manager, state: .previewing) == "ー") + let (nextSpace, nextTransition) = route(.space(prefersFullWidthWhenInput: false), state: .previewing, live: live) + guard case .enterCandidateSelectionMode = nextSpace, case .transition(.selecting) = nextTransition else { + Issue.record("The second Space must enter candidate selection") + return + } + } + manager.insertCompositionSeparator(inputStyle: .roman2kana, skipUpdate: true) + manager.update(requestRichCandidates: true) + let choices = try candidates(manager) + #expect(Array(choices.prefix(2).map(\.text)) == ["ー", "ー"]) + let row = try #require(choices.firstIndex { $0.text == selectedText }) + manager.requestSelectingRow(row) + #expect(marked(manager, state: .selecting) == selectedText) + let selected = try #require(manager.selectedCandidate) + #expect(selected.data.map(\.ruby).joined() == "ー") + #expect(!selected.isLearningTarget) + let (enter, _) = route(.enter, state: .selecting, live: live) + guard case .submitSelectedCandidate = enter else { + Issue.record("Enter in selection must submit the selected candidate") + return + } + manager.prefixCandidateCommited(selected, leftSideContext: "") + #expect(selected.text == selectedText) + #expect(manager.isEmpty) + } + } + + @Test func nonLivePreviewEnterCommitsHalfWidth() throws { + try withManager(live: false) { manager in + input("-", manager: manager) + manager.insertCompositionSeparator(inputStyle: .roman2kana) + manager.requestSetCandidateWindowState(visible: false) + #expect(!manager.isEmpty) + #expect(marked(manager, state: .previewing) == "ー") + let (enter, transition) = route(.enter, state: .previewing, live: false) + guard case .commitMarkedText = enter, case .transition(.none) = transition else { + Issue.record("Preview Enter must commit marked text") + return + } + #expect(manager.commitMarkedText(inputState: .previewing) == "ー") + #expect(manager.isEmpty) + } + } + + @Test(arguments: [false, true]) + func escapeFromSelectionKeepsTheReadingUncommitted(live: Bool) throws { + try withManager(live: live) { manager in + input("-", manager: manager) + manager.update(requestRichCandidates: true) + _ = try candidates(manager) + manager.requestSelectingRow(1) + #expect(marked(manager, state: .selecting) == "ー") + let (escape, transition) = route(.escape, state: .selecting, live: live) + if live { + guard case .hideCandidateWindow = escape, case .transition(.composing) = transition else { + Issue.record("Escape must leave live selection") + return + } + manager.requestSetCandidateWindowState(visible: false) + #expect(marked(manager) == "ー") + } else { + guard case .enterFirstCandidatePreviewMode = escape, case .transition(.previewing) = transition else { + Issue.record("Escape must return non-live selection to preview") + return + } + manager.insertCompositionSeparator(inputStyle: .roman2kana) + manager.requestSetCandidateWindowState(visible: false) + #expect(marked(manager, state: .previewing) == "ー") + } + #expect(!manager.isEmpty) + #expect(manager.convertTarget == "ー") + } + } + + @Test(arguments: [false, true]) + func repeatedUpdatesDoNotDuplicatePreferredCandidates(live: Bool) throws { + try withManager(live: live) { manager in + input("-", manager: manager) + for rich in [false, true, true, false] { + manager.update(requestRichCandidates: rich) + let choices = try candidates(manager) + #expect(Array(choices.prefix(2).map(\.text)) == ["ー", "ー"]) + for width in ["ー", "ー"] { + #expect(choices.filter { $0.text == width }.count == 1) + } + } + } + } + + @Test(arguments: [false, true]) + func disabledPreservesFullWidthCompositionAndCommit(live: Bool) throws { + try withManager(enabled: false, live: live) { manager in + input("-", manager: manager) + #expect(manager.convertTarget == "ー") + #expect(marked(manager) == "ー") + #expect(manager.commitMarkedText(inputState: .composing) == "ー") + #expect(manager.isEmpty) + } + } + + @Test(arguments: [false, true]) + func backspaceAndEscapeCancelWithoutCommitting(live: Bool) throws { + try withManager(live: live) { manager in + input("-", manager: manager) + let (backspace, _) = route(.backspace, live: live) + guard case .removeLastMarkedText = backspace else { + Issue.record("Backspace must remove marked text") + return + } + manager.deleteBackwardFromCursorPosition() + #expect(manager.isEmpty) + #expect(marked(manager).isEmpty) + input("-", manager: manager) + let (escape, transition) = route(.escape, live: live) + guard case .stopComposition = escape, case .transition(.none) = transition else { + Issue.record("Escape must cancel composition") + return + } + manager.stopComposition() + #expect(manager.isEmpty) + #expect(marked(manager, state: .none).isEmpty) + } + } + + @Test(arguments: [false, true]) + func jisKanaPhysicalHyphenRemainsHo(live: Bool) throws { + try withManager(live: live) { manager in + input("-", manager: manager, style: .mapped(id: .defaultKanaJIS)) + #expect(manager.convertTarget == "ほ") + #expect(marked(manager) == "ほ") + #expect(manager.commitMarkedText(inputState: .composing) == "ほ") + } + } + + @Test(arguments: [false, true], [false, true]) + func coffeeKeepsReadingAndDictionaryCandidate(live: Bool, enabled: Bool) throws { + try withManager(enabled: enabled, live: live) { manager in + input("ko-hi-", manager: manager) + #expect(manager.convertTarget == "こーひー") + #expect(!marked(manager).contains("ー")) + manager.update(requestRichCandidates: true) + let choices = try candidates(manager) + let coffee = try #require(choices.first { $0.text == "コーヒー" }) + #expect(coffee.data.map(\.ruby).joined() == "コーヒー") + manager.prefixCandidateCommited(coffee, leftSideContext: "") + #expect(manager.isEmpty) + } + } + + @Test(arguments: [false, true]) + func appendingAndDeletingReturnsToStandalonePreference(live: Bool) throws { + try withManager(live: live) { manager in + input("-a", manager: manager) + #expect(manager.convertTarget == "ーあ") + #expect(!marked(manager).contains("ー")) + manager.deleteBackwardFromCursorPosition() + #expect(manager.convertTarget == "ー") + #expect(marked(manager) == "ー") + #expect(manager.commitMarkedText(inputState: .composing) == "ー") + } + } +} diff --git a/README.md b/README.md index e710c7a7..34cac3ce 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,15 @@ GitHub Sponsorsをご利用ください。 * AZIKのネイティブサポート +### ハイフンを半角で入力 + +設定の「カスタマイズ」→「入力オプション」で「ハイフンは半角を入力」をオンにすると、 +日本語入力で単独の長音符を変換したときに、半角の `ー` を第一候補として表示します。全角の `ー` も選べます。 +設定名の「ハイフン」は入力キーの呼び方です。この設定が優先する文字は半角長音符 `ー`(U+FF70)で、半角ハイフン `-`(U+002D)とは異なります。 +例えばローマ字入力の `-` キーが対象です。キーを押した時点では確定せず、通常どおり未確定の文字列を変換して確定します。 +「コーヒー」のように語中に含まれる長音符は従来どおりです。 +既定ではオフです。英数入力や、確定済みの文章には適用されません。 + ## 開発ガイド コントリビュート歓迎です!! @@ -151,3 +160,5 @@ Thanks to authors!! ## Acknowledgement 本プロジェクトは情報処理推進機構(IPA)による[2024年度未踏IT人材発掘・育成事業](https://www.ipa.go.jp/jinzai/mitou/it/2024/koubokekka.html)の支援を受けて開発を行いました。 + +英数字と区切り記号だけの入力では、`457ー0067` → `457ー0067` / `457-0067` のように半角の候補も選べます。郵便番号・電話番号・英数字コードに共通して対応し、英数字の幅はそのまま残します。候補の追加なので自動確定はしません。この選択肢は単独長音符の優先設定のオン/オフによらず利用できます。かな・漢字を含む語中の長音符は変更しません。 diff --git a/azooKeyMac/Windows/ConfigWindow.swift b/azooKeyMac/Windows/ConfigWindow.swift index 099d4307..f03445ae 100644 --- a/azooKeyMac/Windows/ConfigWindow.swift +++ b/azooKeyMac/Windows/ConfigWindow.swift @@ -752,6 +752,7 @@ struct ConfigWindow: View { keys: [ Config.TypeBackSlash.key, Config.TypeHalfSpace.key, + Config.TypeHalfWidthLongVowelMark.key, Config.OptionDirectFullWidthInput.key, Config.PunctuationStyle.key ]