Skip to content
Open
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
3 changes: 3 additions & 0 deletions Core/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ let package = Package(
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "ZIPFoundation", package: "ZIPFoundation")
],
resources: [
.process("Resources")
],
swiftSettings: [.interoperabilityMode(.Cxx)],
plugins: [
.plugin(name: "GitInfoPlugin")
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 @@ -68,4 +68,10 @@ extension Config {
static let `default` = true
public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.includeContextInAITransform"
}
/// 絵文字入力モード(":"などで起動)を有効化する設定
public struct EmojiInputEnabled: BoolConfigItem {
public init() {}
static let `default` = true
public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.emojiInputEnabled"
}
}
18 changes: 18 additions & 0 deletions Core/Sources/Core/Configs/StringConfigItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,22 @@ extension Config {
public struct PromptHistory: StringConfigItem {
public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.PromptHistory"
}

/// 絵文字入力モードを起動するトリガー文字(デフォルト: 全角コロン ":")
public struct EmojiInputTrigger: StringConfigItem {
public init() {}

public static let `default`: String = ":"
public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.emojiInputTrigger"

public var value: String {
get {
let stored = UserDefaults.standard.string(forKey: Self.key) ?? ""
return stored.isEmpty ? Self.default : stored
}
nonmutating set {
UserDefaults.standard.set(newValue, forKey: Self.key)
}
}
}
}
9 changes: 9 additions & 0 deletions Core/Sources/Core/InputUtils/Actions/ClientAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ public enum ClientAction {
case cancelUnicodeInput
case submitSelectedCandidateAndEnterUnicodeInputMode

// Emoji Input (`:` in Japanese mode)
case enterEmojiInputMode
case appendToEmojiInput(String)
case removeLastEmojiInput
case submitSelectedEmojiCandidate
case cancelEmojiInput
case selectNextEmojiCandidate
case selectPrevEmojiCandidate

case stopComposition
}

Expand Down
95 changes: 95 additions & 0 deletions Core/Sources/Core/InputUtils/EmojiDictionary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import Foundation

public struct EmojiEntry: Sendable, Hashable {
public let emoji: String
public let shortnames: [String]

public init(emoji: String, shortnames: [String]) {
self.emoji = emoji
self.shortnames = shortnames
}
}

public enum EmojiDictionary {
public static func search(query: String, limit: Int = 30) -> [EmojiEntry] {
let q = query.lowercased()
if q.isEmpty {
return []
}
var prefixMatches: [EmojiEntry] = []
var substringMatches: [EmojiEntry] = []
for entry in entries {
var matched: Match = .none
for key in entry.shortnames {
let lk = key.lowercased()
if lk.hasPrefix(q) {
matched = .prefix
break
} else if lk.contains(q) {
matched = max(matched, .substring)
}
}
switch matched {
case .prefix:
prefixMatches.append(entry)
case .substring:
substringMatches.append(entry)
case .none:
break
}
if prefixMatches.count >= limit {
break
}
}
let combined = prefixMatches + substringMatches
return Array(combined.prefix(limit))
}

private enum Match: Int, Comparable {
case none = 0, substring = 1, prefix = 2
static func < (lhs: Match, rhs: Match) -> Bool { lhs.rawValue < rhs.rawValue }
}

// iamcal/emoji-data の emoji.json をバンドルから読み込む。
public static let entries: [EmojiEntry] = loadEntries()

private struct RawEmoji: Decodable {
let unified: String
let shortNames: [String]
let sortOrder: Int?

enum CodingKeys: String, CodingKey {
case unified
case shortNames = "short_names"
case sortOrder = "sort_order"
}
}

private static func loadEntries() -> [EmojiEntry] {
guard let url = Bundle.module.url(forResource: "emoji", withExtension: "json"),
let data = try? Data(contentsOf: url),
let raws = try? JSONDecoder().decode([RawEmoji].self, from: data) else {
return []
}
let sorted = raws.sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) }
return sorted.compactMap { raw in
guard let emoji = emojiString(fromUnified: raw.unified) else {
return nil
}
return EmojiEntry(emoji: emoji, shortnames: raw.shortNames)
}
}

private static func emojiString(fromUnified unified: String) -> String? {
let scalars = unified.split(separator: "-").compactMap { seg -> Unicode.Scalar? in
guard let code = UInt32(seg, radix: 16) else {
return nil
}
return Unicode.Scalar(code)
}
guard !scalars.isEmpty else {
return nil
}
return String(String.UnicodeScalarView(scalars))
}
}
98 changes: 97 additions & 1 deletion Core/Sources/Core/InputUtils/InputState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public enum InputState: Sendable, Hashable {
case selecting
case replaceSuggestion
case unicodeInput(String)
case emojiInput(String)
/// composing 中から入った絵文字モード。composing を保持したまま絵文字を合流させる。
case emojiInputNested(String)

// この種のコードは複雑にしかならないので、lintを無効にする
// swiftlint:disable:next cyclomatic_complexity
Expand All @@ -18,7 +21,13 @@ public enum InputState: Sendable, Hashable {
inputLanguage: InputLanguage,
liveConversionEnabled: Bool,
enableDebugWindow: Bool,
enableSuggestion: Bool
enableSuggestion: Bool,
// 絵文字モード関連のデフォルトは「無効」。
// 呼び出し元 (InputController) が Config から値を読んで明示的に渡す想定で、
// InputController 以外の経路 (言語切替時の内部 event 呼び出し等) で誤発動しないようにするため。
// Config.EmojiInputEnabled().default は `true` だが、それは UI 経由のデフォルト値なので別問題。
emojiInputEnabled: Bool = false,
emojiInputTrigger: String = ":"
) -> (ClientAction, ClientActionCallback) {
if event.modifierFlags.contains(.command) {
return (.fallthrough, .fallthrough)
Expand Down Expand Up @@ -52,6 +61,10 @@ public enum InputState: Sendable, Hashable {
case .input(let string):
switch inputLanguage {
case .japanese:
// 設定で有効かつ、インテンション文字列がトリガーに一致したら絵文字入力モードへ
if emojiInputEnabled && string.inputString(preferIntention: true) == emojiInputTrigger {
return (.enterEmojiInputMode, .transition(.emojiInput("")))
}
return (.appendPieceToMarkedText(string), .transition(.composing))
case .english:
// 連結する
Expand Down Expand Up @@ -122,6 +135,10 @@ public enum InputState: Sendable, Hashable {
case .composing:
switch userAction {
case .input(let string):
// 日本語モードで設定のトリガー文字を押すと、composingを保持したまま入れ子の絵文字入力モードに入る
if emojiInputEnabled && inputLanguage == .japanese && string.inputString(preferIntention: true) == emojiInputTrigger {
return (.enterEmojiInputMode, .transition(.emojiInputNested("")))
}
return (.appendPieceToMarkedText(string), .fallthrough)
case .number(let number):
return (.appendPieceToMarkedText([number.inputPiece]), .fallthrough)
Expand Down Expand Up @@ -197,6 +214,10 @@ public enum InputState: Sendable, Hashable {
case .previewing:
switch userAction {
case .input(let string):
// 日本語モードでトリガー文字を押すと composing を保持して入れ子の絵文字入力モードへ
if emojiInputEnabled && inputLanguage == .japanese && string.inputString(preferIntention: true) == emojiInputTrigger {
return (.enterEmojiInputMode, .transition(.emojiInputNested("")))
}
return (.commitMarkedTextAndAppendPieceToMarkedText(string), .transition(.composing))
case .number(let number):
return (.commitMarkedTextAndAppendPieceToMarkedText([number.inputPiece]), .transition(.composing))
Expand Down Expand Up @@ -258,6 +279,10 @@ public enum InputState: Sendable, Hashable {
} else if s == "D" && enableDebugWindow {
return (.disableDebugWindow, .fallthrough)
}
// 日本語モードでトリガー文字を押すと composing を保持して入れ子の絵文字入力モードへ
if emojiInputEnabled && inputLanguage == .japanese && s == emojiInputTrigger {
return (.enterEmojiInputMode, .transition(.emojiInputNested("")))
}
// FIXME: ここの動作はmacOSの標準と異なる。具体的には、macOSの標準ではselectingをcomposingに戻して入力を継続する動きになる。
return (.commitMarkedTextAndAppendPieceToMarkedText(string), .transition(.composing))
case .enter:
Expand Down Expand Up @@ -399,6 +424,77 @@ public enum InputState: Sendable, Hashable {
case .英数, .かな, .tab, .forget, .function, .navigation, .editSegment, .suggest, .transformSelectedText, .deadKey, .startUnicodeInput, .unknown:
return (.consume, .fallthrough)
}
case .emojiInput(let query):
// トップレベルの絵文字モード: 確定時に .none に戻る (composing なし)
return Self.handleEmojiInputEvent(
query: query,
userAction: userAction,
emojiInputTrigger: emojiInputTrigger,
exitState: .none,
stayInState: { .emojiInput($0) }
)
case .emojiInputNested(let query):
// 入れ子の絵文字モード: 確定時に .composing に戻る (composing 保持)
return Self.handleEmojiInputEvent(
query: query,
userAction: userAction,
emojiInputTrigger: emojiInputTrigger,
exitState: .composing,
stayInState: { .emojiInputNested($0) }
)
}
}

// この種のコードは複雑にしかならないので、lintを無効にする
// swiftlint:disable:next cyclomatic_complexity
private static func handleEmojiInputEvent(
query: String,
userAction: UserAction,
emojiInputTrigger: String,
exitState: InputState,
stayInState: (String) -> InputState
) -> (ClientAction, ClientActionCallback) {
switch userAction {
case .input(let pieces):
// もう一度 トリガー文字を打ったら選択中の候補を確定
if pieces.inputString(preferIntention: true) == emojiInputTrigger {
if query.isEmpty {
return (.cancelEmojiInput, .transition(exitState))
}
return (.submitSelectedEmojiCandidate, .transition(exitState))
}
// ASCII英数・アンダースコア・ハイフン・プラスのみ受け付ける
let input = pieces.inputString(preferIntention: false)
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
let filtered = input.unicodeScalars.filter { allowed.contains($0) }.map { String($0) }.joined()
if !filtered.isEmpty {
return (.appendToEmojiInput(filtered), .transition(stayInState(query + filtered)))
}
return (.consume, .fallthrough)
case .number(let number):
let digit = number.inputString
return (.appendToEmojiInput(digit), .transition(stayInState(query + digit)))
case .backspace:
if query.isEmpty {
return (.cancelEmojiInput, .transition(exitState))
}
return (.removeLastEmojiInput, .transition(stayInState(String(query.dropLast()))))
case .enter, .space:
// query 空でも submit を通す (InputController側で "<trigger>" を残す/破棄判定)
return (.submitSelectedEmojiCandidate, .transition(exitState))
case .escape:
return (.cancelEmojiInput, .transition(exitState))
case .navigation(let direction):
switch direction {
case .down:
return (.selectNextEmojiCandidate, .fallthrough)
case .up:
return (.selectPrevEmojiCandidate, .fallthrough)
case .left, .right:
return (.consume, .fallthrough)
}
case .英数, .かな, .function, .editSegment, .tab, .forget, .suggest, .transformSelectedText, .deadKey, .startUnicodeInput, .unknown:
return (.consume, .fallthrough)
}
}
}
Loading
Loading