Skip to content

Commit e6abb31

Browse files
authored
support vim mode (#280)
1 parent fcd2f33 commit e6abb31

File tree

11 files changed

+165
-43
lines changed

11 files changed

+165
-43
lines changed

assets/po/zh_CN.po

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,31 +27,35 @@ msgstr "切换输入法"
2727
msgid "Menu"
2828
msgstr "菜单"
2929

30-
#: macosfrontend/macosfrontend.h:45
30+
#: macosfrontend/macosfrontend.h:53
3131
msgid "Status bar"
3232
msgstr "状态栏"
3333

34-
#: macosfrontend/macosfrontend.h:48
34+
#: macosfrontend/macosfrontend.h:56
3535
msgid "App default IM"
3636
msgstr "应用默认输入法"
3737

38-
#: macosfrontend/macosfrontend.h:50
38+
#: macosfrontend/macosfrontend.h:58
39+
msgid "Vim mode"
40+
msgstr "Vim 模式"
41+
42+
#: macosfrontend/macosfrontend.h:60
3943
msgid "Simulate key release"
4044
msgstr "模拟按键释放"
4145

42-
#: macosfrontend/macosfrontend.h:53
46+
#: macosfrontend/macosfrontend.h:63
4347
msgid "Delay of simulated key release in milliseconds"
4448
msgstr "模拟按键释放延迟毫秒数"
4549

46-
#: macosfrontend/macosfrontend.h:56
50+
#: macosfrontend/macosfrontend.h:66
4751
msgid "Monitor Pasteboard"
4852
msgstr "监视剪贴板"
4953

50-
#: macosfrontend/macosfrontend.h:58
54+
#: macosfrontend/macosfrontend.h:68
5155
msgid "Remove tracking parameters"
5256
msgstr "清除追踪参数"
5357

54-
#: macosfrontend/macosfrontend.h:61
58+
#: macosfrontend/macosfrontend.h:71
5559
msgid "Poll Pasteboard interval (s)"
5660
msgstr "轮询剪贴板的间隔(秒)"
5761

macosfrontend/macosfrontend.cpp

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,39 @@ std::string MacosFrontend::keyEvent(ICUUID uuid, const Key &key, bool isRelease,
141141
auto timeEventPtr = timeEvent.release();
142142
}
143143

144+
bool keepVimPreedit = false;
145+
if (ic->vimMode() && !keyEvent.accepted() &&
146+
imGetCurrentIMName() != "keyboard-us") {
147+
if (key.check(FcitxKey_Escape) ||
148+
key.check(FcitxKey_bracketleft, KeyState::Ctrl) ||
149+
key.check(FcitxKey_c, KeyState::Ctrl)) {
150+
// If the dummy preedit hack on Ctrl was needed, it has to be
151+
// preserved here, otherwise vim won't enter normal mode. Make this
152+
// explicit, as some engines (rime, mozc) will call
153+
// updateUserInterface(UserInterfaceComponent::InputPanel) even if
154+
// input panel was and keeps empty, which makes webpanel clear the
155+
// dummy preedit.
156+
keepVimPreedit = true;
157+
imSetCurrentIM("keyboard-us");
158+
} else if (!ic->inputPanel().transient() && ic->inputPanel().empty()) {
159+
// HACK: For Terminal and iTerm, when pressing an handled ctrl, we
160+
// force a dummy preedit so that the following c or [ could be
161+
// processed by fcitx. It's known that Esc won't work in Terminal,
162+
// but we don't want to force dummy preedit unconditionally as it
163+
// breaks other keys.
164+
if (!isRelease &&
165+
(ic->program() == "com.apple.Terminal" ||
166+
ic->program() == "com.googlecode.iterm2") &&
167+
(key.check(FcitxKey_Control_L, KeyState::Ctrl) ||
168+
key.check(FcitxKey_Control_R, KeyState::Ctrl))) {
169+
keepVimPreedit = true;
170+
ic->setVimPreedit(true);
171+
}
172+
}
173+
}
174+
if (!keepVimPreedit) {
175+
ic->setVimPreedit(false);
176+
}
144177
return ic->popState(keyEvent.accepted());
145178
}
146179

@@ -198,6 +231,12 @@ void MacosFrontend::useAppDefaultIM(const std::string &appId) {
198231
}
199232
}
200233

234+
void MacosFrontend::useVimMode(const std::string &appId,
235+
MacosInputContext *ic) {
236+
const auto &vimMode = *config_.vimMode;
237+
ic->setVimMode(std::ranges::find(vimMode, appId) != vimMode.end());
238+
}
239+
201240
void MacosFrontend::focusIn(ICUUID uuid, bool isPassword) {
202241
auto *ic = findIC(uuid);
203242
if (!ic)
@@ -207,7 +246,10 @@ void MacosFrontend::focusIn(ICUUID uuid, bool isPassword) {
207246
ic->focusIn();
208247
auto program = ic->program();
209248
FCITX_INFO() << "Focus in " << program;
210-
useAppDefaultIM(program);
249+
if (!program.empty()) {
250+
useAppDefaultIM(program);
251+
useVimMode(program, ic);
252+
}
211253
}
212254

213255
std::string MacosFrontend::commitComposition(ICUUID uuid) {
@@ -282,7 +324,7 @@ std::string MacosInputContext::popState(bool accepted) {
282324
j["commit"] = state_.commit;
283325
j["preedit"] = state_.preedit;
284326
j["caretPos"] = state_.caretPos;
285-
j["dummyPreedit"] = state_.dummyPreedit;
327+
j["dummyPreedit"] = state_.dummyPreedit || state_.vimPreedit;
286328
j["accepted"] = accepted;
287329
resetState();
288330
return j.dump();

macosfrontend/macosfrontend.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,23 @@ struct AppIMAnnotation {
3939
}
4040
};
4141

42+
struct VimModeAnnotation {
43+
bool skipDescription() { return false; }
44+
bool skipSave() { return false; }
45+
void dumpDescription(RawConfig &config) {
46+
config.setValueByPath("VimMode", "True");
47+
}
48+
};
49+
4250
FCITX_CONFIGURATION(
4351
MacosFrontendConfig,
4452
OptionWithAnnotation<StatusBar, StatusBarI18NAnnotation> statusBar{
4553
this, "StatusBar", _("Status bar"), StatusBar::Menu};
4654
OptionWithAnnotation<std::vector<std::string>, AppIMAnnotation>
4755
appDefaultIM{
4856
this, "AppDefaultIM", _("App default IM"), {TERMINAL_USE_EN}};
57+
OptionWithAnnotation<std::vector<std::string>, VimModeAnnotation> vimMode{
58+
this, "VimMode", _("Vim mode"), {"org.vim.MacVim"}};
4959
Option<bool> simulateKeyRelease{this, "SimulateKeyRelease",
5060
_("Simulate key release")};
5161
Option<int, IntConstrain> simulateKeyReleaseDelay{
@@ -105,13 +115,15 @@ class MacosFrontend : public AddonInstance {
105115

106116
inline MacosInputContext *findIC(ICUUID);
107117
void useAppDefaultIM(const std::string &appId);
118+
void useVimMode(const std::string &appId, MacosInputContext *ic);
108119
};
109120

110121
struct InputContextState {
111122
std::string commit;
112123
std::string preedit;
113124
int caretPos;
114125
bool dummyPreedit;
126+
bool vimPreedit;
115127
};
116128

117129
class MacosInputContext : public InputContext {
@@ -139,6 +151,7 @@ class MacosInputContext : public InputContext {
139151
void setDummyPreedit(bool dummyPreedit) {
140152
state_.dummyPreedit = dummyPreedit;
141153
}
154+
void setVimPreedit(bool vimPreedit) { state_.vimPreedit = vimPreedit; }
142155
std::string popState(bool accepted);
143156
// Shows whether we are processing a sync event (mainly key down) that needs
144157
// to return a bool to indicate if it's handled. In this case, commit and
@@ -148,12 +161,15 @@ class MacosInputContext : public InputContext {
148161
void commitAndSetPreeditAsync();
149162

150163
void setPassword(bool isPassword);
164+
void setVimMode(bool vimMode) { vimMode_ = vimMode; }
165+
bool vimMode() const { return vimMode_; }
151166

152167
private:
153168
MacosFrontend *frontend_;
154169
id client_;
155170
InputContextState state_;
156171
std::string accentColor_;
172+
bool vimMode_ = false;
157173
};
158174

159175
class MacosFrontendFactory : public AddonFactory {

src/config/appimoptionview.swift

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ private let presetApps: [String] = [
88
"/System/Library/Input Methods/CharacterPalette.app", // emoji picker
99
]
1010

11-
private func image(_ appPath: String) -> Image {
12-
let icon = NSWorkspace.shared.icon(forFile: appPath)
13-
return Image(nsImage: icon)
14-
}
15-
1611
struct AppIMOptionView: OptionView {
1712
let label: String
1813
let overrideLabel: String? = nil
@@ -31,7 +26,7 @@ struct AppIMOptionView: OptionView {
3126
var body: some View {
3227
HStack {
3328
if !model.appPath.isEmpty {
34-
image(model.appPath)
29+
appIconFromPath(model.appPath)
3530
}
3631
Picker("", selection: $model.appPath) {
3732
ForEach(selections(), id: \.self) { key in
@@ -40,15 +35,19 @@ struct AppIMOptionView: OptionView {
4035
} else {
4136
HStack {
4237
if model.appPath != key {
43-
image(key)
38+
appIconFromPath(key)
4439
}
4540
Text(appNameFromPath(key)).tag(key)
4641
}
4742
}
4843
}
4944
}
5045
Button {
51-
openSelector()
46+
selectApplication(
47+
openPanel,
48+
onFinish: { path in
49+
model.appPath = path
50+
})
5251
} label: {
5352
Image(systemName: "folder")
5453
}
@@ -71,19 +70,4 @@ struct AppIMOptionView: OptionView {
7170
}
7271
}
7372
}
74-
75-
private func openSelector() {
76-
openPanel.allowsMultipleSelection = false
77-
openPanel.canChooseDirectories = false
78-
openPanel.allowedContentTypes = [.application]
79-
openPanel.directoryURL = URL(fileURLWithPath: "/Applications")
80-
openPanel.begin { response in
81-
if response == .OK {
82-
let selectedApp = openPanel.urls.first
83-
if let appURL = selectedApp {
84-
model.appPath = appURL.localPath()
85-
}
86-
}
87-
}
88-
}
8973
}

src/config/config.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ private func jsonToOption(_ json: JSON, _ type: String) throws -> any Option {
103103
if let appIM = json["AppIM"].string, appIM == "True" {
104104
return try ListOption<AppIMOption>.decode(json: json)
105105
}
106+
if let vim = json["VimMode"].string, vim == "True" {
107+
return try ListOption<VimModeOption>.decode(json: json)
108+
}
106109
if let plugin = json["Plugin"].string, plugin == "True" {
107110
return try ListOption<PluginOption>.decode(json: json)
108111
}

src/config/optionmodels.swift

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -334,17 +334,7 @@ class CssOption: StringOption {}
334334

335335
class PluginOption: StringOption {}
336336

337-
private func bundleIdentifier(_ appPath: String) -> String {
338-
guard let bundle = Bundle(path: appPath) else {
339-
return ""
340-
}
341-
return bundle.bundleIdentifier ?? ""
342-
}
343-
344-
func appNameFromPath(_ path: String) -> String {
345-
let name = URL(filePath: path).lastPathComponent
346-
return name.hasSuffix(".app") ? String(name.dropLast(4)) : name
347-
}
337+
class VimModeOption: StringOption {}
348338

349339
class AppIMOption: Option, ObservableObject, EmptyConstructible {
350340
typealias Storage = String

src/config/optionviews.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,8 @@ func buildViewImpl(label: String, option: any Option) -> any OptionView {
560560
return CssOptionView(label: label, model: option)
561561
} else if let option = option as? AppIMOption {
562562
return AppIMOptionView(label: label, model: option)
563+
} else if let option = option as? VimModeOption {
564+
return VimModeOptionView(label: label, model: option)
563565
} else if let option = option as? PluginOption {
564566
return PluginOptionView(label: label, model: option)
565567
} else if let option = option as? KeyOption {
@@ -580,6 +582,8 @@ func buildViewImpl(label: String, option: any Option) -> any OptionView {
580582
return ListOptionView<FontOption>(label: label, model: option)
581583
} else if let option = option as? ListOption<AppIMOption> {
582584
return ListOptionView<AppIMOption>(label: label, model: option)
585+
} else if let option = option as? ListOption<VimModeOption> {
586+
return ListOptionView<VimModeOption>(label: label, model: option)
583587
} else if let option = option as? ListOption<PluginOption> {
584588
return ListOptionView<PluginOption>(label: label, model: option)
585589
} else if let option = option as? ListOption<KeyOption> {

src/config/ui.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,23 @@ struct SelectFileButton<Label>: View where Label: View {
148148
}
149149
}
150150
}
151+
152+
func selectApplication(_ openPanel: NSOpenPanel, onFinish: @escaping (String) -> Void) {
153+
openPanel.allowsMultipleSelection = false
154+
openPanel.canChooseDirectories = false
155+
openPanel.allowedContentTypes = [.application]
156+
openPanel.directoryURL = URL(fileURLWithPath: "/Applications")
157+
openPanel.begin { response in
158+
if response == .OK {
159+
let selectedApp = openPanel.urls.first
160+
if let appURL = selectedApp {
161+
onFinish(appURL.localPath())
162+
}
163+
}
164+
}
165+
}
166+
167+
func appIconFromPath(_ path: String) -> Image {
168+
let icon = NSWorkspace.shared.icon(forFile: path)
169+
return Image(nsImage: icon)
170+
}

src/config/util.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,26 @@ func getNoCacheSession() -> URLSession {
228228
config.requestCachePolicy = .reloadIgnoringLocalCacheData
229229
return URLSession(configuration: config)
230230
}
231+
232+
func bundleIdentifier(_ appPath: String) -> String {
233+
guard let bundle = Bundle(path: appPath) else {
234+
return ""
235+
}
236+
return bundle.bundleIdentifier ?? ""
237+
}
238+
239+
func appPathFromBundleIdentifier(_ bundleID: String) -> String {
240+
// Must check empty, otherwise it uses the last opened app that has no bundleID.
241+
if bundleID.isEmpty {
242+
return ""
243+
}
244+
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
245+
return url.localPath()
246+
}
247+
return ""
248+
}
249+
250+
func appNameFromPath(_ path: String) -> String {
251+
let name = URL(filePath: path).lastPathComponent
252+
return name.hasSuffix(".app") ? String(name.dropLast(4)) : ""
253+
}

src/config/vimmodeoptionview.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import SwiftUI
2+
3+
struct VimModeOptionView: OptionView {
4+
let label: String
5+
let overrideLabel: String? = nil
6+
let openPanel = NSOpenPanel()
7+
@ObservedObject var model: VimModeOption
8+
9+
var body: some View {
10+
HStack {
11+
let appPath = appPathFromBundleIdentifier(model.value)
12+
let appName = appNameFromPath(appPath)
13+
if !appPath.isEmpty {
14+
appIconFromPath(appPath)
15+
}
16+
Spacer()
17+
if !appName.isEmpty {
18+
Text(appName)
19+
} else if model.value.isEmpty {
20+
Text("Select App")
21+
} else {
22+
Text(model.value)
23+
}
24+
Button {
25+
selectApplication(
26+
openPanel,
27+
onFinish: { path in
28+
model.value = bundleIdentifier(path)
29+
})
30+
} label: {
31+
Image(systemName: "folder")
32+
}
33+
}
34+
}
35+
}

0 commit comments

Comments
 (0)