Skip to content

Commit

Permalink
Implement GoVarnam Inpue Method Engine (#2)
Browse files Browse the repository at this point in the history
* Fix links
* Improve build script
* Remove individual binaries before new build
* Ignore dylib
* Add govarnam library
* Disable AppSandbox for IME to register NSConnection. Reference: https://blog.inoki.cc/2021/06/19/Write-your-own-IME-on-macOS-1/
* IME works but no candidates yet
* Candidate showing works! Return value from handle is very important!
* Made improvements to candidate shower
* Refactor: Remove unnecessary code
* Remove clientmanager, inputcontroller dependency on lipika-engine
* Cursor can now move left, right
* Set default orientation vertical
* Close window on Escape key
* Select first candidate on enter key press
* Add resources
* Remove duplicate entries because candidatesWindow makes them hidden
* candidate selection change doesn't work
* Moving up/down in candidates window works
* Add more resources
* Select candidate based on cursor in candidate table
  • Loading branch information
subins2000 authored Nov 14, 2021
1 parent 4c3e48e commit 7bb264c
Show file tree
Hide file tree
Showing 14 changed files with 417 additions and 287 deletions.
15 changes: 13 additions & 2 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,23 @@ Easily type Indian languages on macOS using [Varnam transliteration engine](http

Built at FOSSUnited's [FOSSHack21](https://fossunited.org/fosshack/2021/project?project=Type%20Indian%20Languages%20natively%20on%20Mac).

This project is a hard-fork of [lipika-ime](github.com/ratreya/Lipika_IME). Changes made:
This project is a hard-fork of [lipika-ime](https://github.com/ratreya/Lipika_IME). Changes made:
* https://github.com/varnamproject/varnam-macOS/pull/1

There aren't many documentation on how to make IMEs for macOS, especially in **English**. Getting started with XCode is also tricky for beginners. Setting up **Lipika** was also difficult.

Resources that helped in making IME on macOS (ordered by most important to the least):
* https://blog.inoki.cc/2021/06/19/Write-your-own-IME-on-macOS-1/ (The last section is very important!)
* https://jyhong836.github.io/tech/2015/07/29/add-3rd-part-dynamic-library-dylib-to-xcode-target.html
* https://github.com/lennylxx/google-input-tools-macos (An IME made 2 months ago, Has GitHub CI builds)
* https://github.com/nh7a/hiragany (Simple Japanese IME)
* https://github.com/pkamb/NumberInput_IMKit_Sample/issues/1
* API Docs: https://developer.apple.com/documentation/inputmethodkit/imkcandidates

## License

> Copyright (C) 2018 Ranganath Atreya
>
> Copyright (C) 2021 Subin Siby
```
Expand All @@ -18,4 +29,4 @@ General Public License as published by the Free Software Foundation; either vers
or (at your option) any later version.
This program comes with ABSOLUTELY NO WARRANTY; see LICENSE file.
```
```
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "GoVarnam/govarnam"]
path = GoVarnam/govarnam
url = [email protected]:varnamproject/govarnam.git
2 changes: 1 addition & 1 deletion Application/LiteratorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class LiteratorModel: ObservableObject {
return self.ante!.anteliterate(lit.finalaizedOutput + lit.unfinalaizedOutput)
}
case (0, 1):
let override: [String: MappingValue]? = MappingStore.read(schemeName: self.fromScheme, scriptName: self.toScript)
let override: [String: MappingValue]? = .read(schemeName: self.fromScheme, scriptName: self.toScript)
self.trans = try! self.factory.transliterator(schemeName: self.fromScheme, scriptName: self.toScript, mappings: override)
eval = { (input: String) -> String in
_ = self.trans!.reset()
Expand Down
1 change: 1 addition & 0 deletions GoVarnam/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.dylib
62 changes: 62 additions & 0 deletions GoVarnam/Varnam.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// Varnam.swift
// VarnamIME
//
// Created by Subin on 13/11/21.
// Copyright © 2021 VarnamProject. All rights reserved.
//

import Foundation

struct VarnamException: Error {
let message: String

init(_ message: String) {
self.message = message
}

public var localizedDescription: String {
return message
}
}

public class Varnam {
private var varnamHandle: Int32 = 0;

internal init(_ schemeID: String = "ml") throws {
schemeID.withCString {
let rc = varnam_init_from_id(UnsafeMutablePointer(mutating: $0), &varnamHandle)
try! checkError(rc)
}
}

public func getLastError() -> String {
return String(cString: varnam_get_last_error(varnamHandle))
}

public func checkError(_ rc: Int32) throws {
if (rc != VARNAM_SUCCESS) {
throw VarnamException(getLastError())
}
}

public func transliterate(_ input: String) -> [String] {
var arr: UnsafeMutablePointer<varray>? = varray_init()
let cInput = (input as NSString).utf8String
varnam_transliterate(
varnamHandle,
1,
UnsafeMutablePointer(mutating: cInput),
&arr
)

var results = [String]()
for i in (0..<varray_length(arr)) {
let sug = varray_get(arr, i).assumingMemoryBound(to: Suggestion.self
)
let word = String(cString: sug.pointee.Word)
results.append(word)
}
return results
}
}
5 changes: 5 additions & 0 deletions GoVarnam/VarnamIME-Bridging-Header.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

#include "govarnam/libgovarnam.h"
4 changes: 4 additions & 0 deletions GoVarnam/build_govarnam.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
cd govarnam
make library-mac-universal
install_name_tool -id @executable_path/../Frameworks/libgovarnam.dylib libgovarnam.dylib || exit 1
cp ./libgovarnam.dylib ../
1 change: 1 addition & 0 deletions GoVarnam/govarnam
Submodule govarnam added at c75428
5 changes: 4 additions & 1 deletion Input Source/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
Logger.log.debug("Initialized IMK Server: \(server.bundle().bundleIdentifier ?? "nil")")
self.server = server
candidatesWindow = IMKCandidates(server: server, panelType: kIMKSingleRowSteppingCandidatePanel)

// Panel type is the orientation. Default: Vertical
// Use kIMKSingleRowSteppingCandidatePanel for horizontal
candidatesWindow = IMKCandidates(server: server, panelType: kIMKSingleColumnScrollingCandidatePanel)
candidatesWindow.setAttributes([IMKCandidatesSendServerKeyEventFirst: NSNumber(booleanLiteral: true)])
}

Expand Down
115 changes: 45 additions & 70 deletions Input Source/ClientManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
*/

import InputMethodKit
import LipikaEngine_OSX

class ClientManager: CustomStringConvertible {
private let notFoundRange = NSMakeRange(NSNotFound, NSNotFound)
private let config = VarnamConfig()
private let client: IMKTextInput
// This is the position of the cursor within the marked text
public var markedCursorLocation: Int? = nil

private var candidatesWindow: IMKCandidates { return (NSApp.delegate as! AppDelegate).candidatesWindow }
private (set) var candidates = autoreleasepool { return [String]() }
private var tableCursorPos = 0 // Candidates table cursor position

// Cache, otherwise clients quitting can sometimes SEGFAULT us
private var _description: String
var description: String {
Expand All @@ -31,101 +31,76 @@ class ClientManager: CustomStringConvertible {

init?(client: IMKTextInput) {
guard let bundleId = client.bundleIdentifier(), let clientId = client.uniqueClientIdentifierString() else {
Logger.log.warning("bundleIdentifier: \(client.bundleIdentifier() ?? "nil") or uniqueClientIdentifierString: \(client.uniqueClientIdentifierString() ?? "nil") - failing ClientManager.init()")
Log.warning("bundleIdentifier: \(client.bundleIdentifier() ?? "nil") or uniqueClientIdentifierString: \(client.uniqueClientIdentifierString() ?? "nil") - failing ClientManager.init()")
return nil
}
Logger.log.debug("Initializing client: \(bundleId) with Id: \(clientId)")
Log.debug("Initializing client: \(bundleId) with Id: \(clientId)")
self.client = client
if !client.supportsUnicode() {
Logger.log.warning("Client: \(bundleId) does not support Unicode!")
Log.warning("Client: \(bundleId) does not support Unicode!")
}
if !client.supportsProperty(TSMDocumentPropertyTag(kTSMDocumentSupportDocumentAccessPropertyTag)) {
Logger.log.warning("Client: \(bundleId) does not support Document Access!")
Log.warning("Client: \(bundleId) does not support Document Access!")
}
_description = "\(bundleId) with Id: \(clientId)"
}

func setGlobalCursorLocation(_ location: Int) {
Logger.log.debug("Setting global cursor location to: \(location)")
Log.debug("Setting global cursor location to: \(location)")
client.setMarkedText("|", selectionRange: NSMakeRange(0, 0), replacementRange: NSMakeRange(location, 0))
client.setMarkedText("", selectionRange: NSMakeRange(0, 0), replacementRange: NSMakeRange(location, 0))
}

func updateMarkedCursorLocation(_ delta: Int) -> Bool {
Logger.log.debug("Cursor moved: \(delta) with selectedRange: \(client.selectedRange()), markedRange: \(client.markedRange()) and cursorPosition: \(markedCursorLocation?.description ?? "nil")")
if client.markedRange().length == NSNotFound { return false }
let nextPosition = (markedCursorLocation ?? client.markedRange().length) + delta
if (0...client.markedRange().length).contains(nextPosition) {
Logger.log.debug("Still within markedRange")
markedCursorLocation = nextPosition
return true
}
Logger.log.debug("Outside of markedRange")
markedCursorLocation = nil
return false
func updatePreedit(_ text: NSAttributedString, _ cursorPos: Int? = nil) {
client.setMarkedText(text, selectionRange: NSMakeRange(cursorPos ?? text.length, 0), replacementRange: notFoundRange)
}

func updateCandidates(_ sugs: [String]) {
Log.debug(sugs)
// Remove duplicates
// For some weird reason, when there are duplicates,
// candidate window makes them hidden
candidates = NSOrderedSet(array: sugs).array as! [String]
updateLookupTable()
}

func updateLookupTable() {
tableCursorPos = 0
candidatesWindow.update()
candidatesWindow.show()
}

func showActive(clientText: NSAttributedString, candidateText: String, replacementRange: NSRange? = nil) {
Logger.log.debug("Showing clientText: \(clientText) and candidateText: \(candidateText)")
client.setMarkedText(clientText, selectionRange: NSMakeRange(markedCursorLocation ?? clientText.length, 0), replacementRange: replacementRange ?? notFoundRange)
candidates = [candidateText]
if clientText.string.isEmpty {
candidatesWindow.hide()
// For moving between items of candidate table
func tableMoveEvent(_ event: NSEvent) {
if event.keyCode == kVK_UpArrow && tableCursorPos > 0 {
// TODO allow moving to the end
// This would need a custom candidate window
// https://github.com/lennylxx/google-input-tools-macos/blob/main/GoogleInputTools/CandidatesWindow.swift
tableCursorPos -= 1
} else if event.keyCode == kVK_DownArrow && tableCursorPos < candidates.count - 1 {
tableCursorPos += 1
}
else {
candidatesWindow.update()
if config.showCandidates {
candidatesWindow.show()
}
candidatesWindow.interpretKeyEvents([event])
}

func getCandidate() -> String? {
if candidates.count == 0 {
return nil
} else {
return candidates[tableCursorPos]
}
}

func finalize(_ output: String) {
Logger.log.debug("Finalizing with: \(output)")
Log.debug("Finalizing with: \(output)")
client.insertText(output, replacementRange: notFoundRange)
candidatesWindow.hide()
markedCursorLocation = nil
}

func clear() {
Logger.log.debug("Clearing MarkedText and Candidate window")
Log.debug("Clearing MarkedText and Candidate window")
client.setMarkedText("", selectionRange: NSMakeRange(0, 0), replacementRange: notFoundRange)
candidates = []
candidatesWindow.hide()
markedCursorLocation = nil
}

func findWord(at current: Int) -> NSRange? {
let maxLength = client.length()
var exponent = 2
var wordStart = -1, wordEnd = -1
Logger.log.debug("Finding word at: \(current) with max: \(maxLength)")
repeat {
let low = wordStart == -1 ? max(current - 2 << exponent, 0): wordStart
let high = wordEnd == -1 ? min(current + 2 << exponent, maxLength): wordEnd
Logger.log.debug("Looking for word between \(low) and \(high)")
var real = NSRange()
guard let text = client.string(from: NSMakeRange(low, high - low), actualRange: &real) else { return nil }
Logger.log.debug("Looking for word in text: \(text)")
if wordStart == -1, let startOffset = text.unicodeScalars[text.unicodeScalars.startIndex..<text.unicodeScalars.index(text.unicodeScalars.startIndex, offsetBy: current - real.location)].reversed().firstIndex(where: { CharacterSet.whitespacesAndNewlines.contains($0) })?.base.utf16Offset(in: text) {
wordStart = real.location + startOffset
Logger.log.debug("Found wordStart: \(wordStart)")
}
if wordEnd == -1, let endOffset = text.unicodeScalars[text.unicodeScalars.index(text.unicodeScalars.startIndex, offsetBy: current - real.location)..<text.unicodeScalars.endIndex].firstIndex(where: { CharacterSet.whitespacesAndNewlines.contains($0) })?.utf16Offset(in: text) {
wordEnd = real.location + endOffset
Logger.log.debug("Found wordEnd: \(wordEnd)")
}
exponent += 1
if wordStart == -1, low == 0 {
wordStart = low
Logger.log.debug("Starting word at beginning of document")
}
if wordEnd == -1, high == maxLength {
wordEnd = high
Logger.log.debug("Ending word at end of document")
}
}
while(wordStart == -1 || wordEnd == -1)
Logger.log.debug("Found word between \(wordStart) and \(wordEnd)")
return NSMakeRange(wordStart, wordEnd - wordStart)
}
}
Loading

0 comments on commit 7bb264c

Please sign in to comment.