Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Desktop: KeymapService: Initial implementation #3252

Merged
merged 15 commits into from
Aug 2, 2020

Conversation

anjulalk
Copy link
Contributor

@anjulalk anjulalk commented May 22, 2020

This PR includes the initial implementation of the KeymapService service. This service is responsible for abstracting the in-memory keymap.

It's now possible to correctly parse keymap-desktop.json file and update the keymap.

  • Replaces hardcoded Accelerators of menu items defined in the ElectronClient/app.js and ElectronClient/plugins/goToAnything.jsx files with the getAccelerator() method defined in the service class mentioned above.
  • Provides a simple setAccelerator() method for updating the in-memory keymap.
  • Provides method getKeymap() which compares current keymap with default keymap and returns an object with the changes. This object can be converted to a string and saved to the disk.
  • Provides method setKeymap() which accepts a custom keymap object as the parameter, validates, checks for duplicate Accelerators in the current platform and updates the in-memory keymap.
  • Uses static method isAccelerator() method for validating Accelerator strings.
  • Provides methods resetCommand() and resetKeymap() which resets shortcut for a single command and restores the entire keymap to default respectively.

The service is implemented using TypeScript classes and interfaces. All the methods currently implemented have been unit tested.

@anjulalk anjulalk force-pushed the keymap-handler branch 2 times, most recently from e839134 to 2be3878 Compare May 29, 2020 09:01
ElectronClient/keymapHandler.ts Outdated Show resolved Hide resolved
ElectronClient/keymapHandler.ts Outdated Show resolved Hide resolved
@anjulalk anjulalk changed the title Desktop: Keymap handler module: initial implementation Desktop: KeymapService: Initial implementation Jun 5, 2020
@anjulalk
Copy link
Contributor Author

anjulalk commented Jun 5, 2020

I've removed label and section properties. I totally agree with your point here. This service should map an Accelerator to a command.

However, we need still to display labels for each command and group them by section in the GUI editor. I'm thinking maybe hard-coding them in the editor's code itself?

For example,
{ command: 'newNoteItem', label: _("New note"), section: 'File' }

I see no other option, at least until we get the actions centralized and sorted out. Would that approach make sense?

@anjulalk anjulalk force-pushed the keymap-handler branch 4 times, most recently from a82a0a3 to 5deec3b Compare June 13, 2020 12:18
Copy link
Owner

@laurent22 laurent22 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update Anjula, I've added a few comments to the code.

Also I think the "CommandAndControl" thing is going to cause us troubles down the line. On Windows and Linux "CommandAndControl" is meaningless. And on macOS it also makes things more complicated - I create a CommandAndControl+S shortcut, so does it work when pressing Cmd+S and Ctrl+S or just one and not the other? Then if I create a separate Cmd+S shortcut, I guess that's a conflict? I also can't wrap my head how it's going to work on platforms without Command.

So my thinking is that we should not use this shortcut and only use either Cmd or Ctrl. In practice, I think it means there should be separate default config for Windows/Linux and for macOS. That's a bit of duplicated config but I think that's fine as it won't change that often. That's how Sublime Text handle the config and I think it makes sense. What do you think?

ElectronClient/app.js Outdated Show resolved Hide resolved
await KeymapService.instance().setKeymap(customKeymap);
} catch (error) {
const msg = error.message ? error.message : '';
console.error(`Failed to parse keymap ${keymapPath}\n${msg}`);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error should be displayed to the user with an explanation on how to fix the problem. For example, by giving the line number where the error happens.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's an error in the JSON formatting, now it'll be shown in an error dialogue to the user (Standard error message thrown by the JSON.parse() function)

I've also added more helpful error messages.

However it's a bit complicated to find the line number of some object. I think we'll have to write a mini JSON parser to do that. Is that really necessary?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However it's a bit complicated to find the line number of some object. I think we'll have to write a mini JSON parser to do that. Is that really necessary?

No that's fine - we just display the errors the lib gives us. If they aren't very good then so be it. Later we can indeed think about using a more advanced parser with comment support, better error message, etc. but that's later.

ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
@laurent22
Copy link
Owner

I thought about it again and as I see it there's just two cases for the keymap:

  • Default keymap, that applies to Linux and Windows (we don't explicitely support FreeBSD)
  • macOS keymap, which is just the default keymap, but with Control changed to Command, plus the Hide Window and Close Window commands.

CommandOrControl modifier should not be present in the keymap.

Please remove the "platforms" property from the keymap. Each platform should have its own separate keymap.json named keymap.macos.json, keymap.windows.json and keymap.linux.json.

Differences like this or this should be removed. All platforms should have the same basic shortcut and it's up to the user to change them to something else if they want.

In general I think your implementation is pretty good. I just wonder about the part where you load the keymap, as you rely on the log to handle errors, but log is just for us developers. We also need to tell the user about any issue, and for that throwing exceptions is best: something is unexpected? Just throw an exception and break the app. That's much better than letting it "work" but with undetected issues. If you throw exceptions from the beginning it will force you to find solutions on how to handle them well - for example by displaying a dialog box or a banner in the app. In some cases logging the error is reasonable too, for example for issues that are very unlikely.

@laurent22
Copy link
Owner

What's your proposition to retrieve the label for a given action? As you will need this for the config screen. I can't really think of any good way without some refactoring but please let me know what you would suggest.

@anjulalk
Copy link
Contributor Author

Could you clarify this a bit more?

Each platform should have its own separate keymap.json named keymap.macos.json, keymap.windows.json and keymap.linux.json.

Are you referring to the default keymap which is included in the source code?

If yes, rather than storing them as separate files, I think we should just include them in the source code as objects. Then we can dynamically use the appropriate keymap object.

I'll make the changes as soon as possible. Thanks for the review.

@anjulalk
Copy link
Contributor Author

What's your proposition to retrieve the label for a given action? As you will need this for the config screen. I can't really think of any good way without some refactoring but please let me know what you would suggest.

I expect there would be a way to get all labels, and all commands after the refactoring. For the moment, I suppose we can hard-code these two properties in the config screen itself.

Would that be appropriate?

@anjulalk
Copy link
Contributor Author

anjulalk commented Jun 24, 2020

I've separated the keymap into two: One for macOS and one for all the other platforms.
Appropriate keymap for the current platform will be chosen at runtime.

I had to use synchronous functions for reading the keymap-desktop.json file as it's not possible to use promises in the constructor.
But I don't really think it matters because,

Menu items are still created even if they're not relevant to the current platform. I've added special checks to prevent calling getAccelerator() with unavailable commands in current platform keymap. Otherwise it'll throw an error.

More helpful errors will be shown to the user in an error dialogue box.

For some reason, the service doesn't seem to be able to access values from Setting model prior to the app initialization. That's why I moved the plugin loading after the app initialization. If GotoAnything plugin creates the service instance first, it cannot access the profile directory and fails to read the file. Hope that's okay.

Please have a look and give your feedback.

@anjulalk anjulalk marked this pull request as ready for review June 25, 2020 11:14
@laurent22
Copy link
Owner

I expect there would be a way to get all labels, and all commands after the refactoring. For the moment, I suppose we can hard-code these two properties in the config screen itself.

Would that be appropriate?

Yes that's reasonable. Please just make sure the labels are exactly the same, so that they don't generate new translation strings (and that of course they are wrapped in _()). That will make it easier to refactor the labels, etc. if it's ever done.

ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ElectronClient/app.js Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
ReactNativeClient/lib/services/KeymapService.ts Outdated Show resolved Hide resolved
@laurent22
Copy link
Owner

@anjulalk, please don't force-push your commits because then it makes it hard to know what has changed since the last time I reviewed. Was the only new commit this one? 680428b8e9f602151748a7fac0fb9aab33ccb83e

@laurent22
Copy link
Owner

@anjulalk, I recently made some important changes to the way the commands work in Joplin, in this commit: c63c637

It will impact your work a bit because the way the accelerators are set is a bit different now, it's done like so: c63c637#diff-bff206348900b3c1ff4cbc14ca186272R509

I expect it shouldn't be too complicated to apply your changes on top though, for example using a regex search and replace, you could replace this:

cmdService.commandToMenuItem('newNote', 'CommandOrControl+N');

to this:

cmdService.commandToMenuItem('newNote', getAccelerator('newNote'));

Perhaps some of the command names will be slightly different too.

Finally, you'll need to change how you get the accelerator label in your Config screen. For that, please add a label(commandName:string):string method to CommandService that will return the label for a command. Have a look at isEnabled to see how it is done.

In any case I would recommend merging master to your branch as soon as possible to avoid diverging too much. And if you have any question about the command service, please let me know.

@anjulalk
Copy link
Contributor Author

anjulalk commented Jul 4, 2020

@anjulalk, please don't force-push your commits because then it makes it hard to know what has changed since the last time I reviewed. Was the only new commit this one? 680428b

Sorry about that. Yes, it's the only new one. I rebased to the latest commits on master branch and it messes up the history a bit.

I'll make the changes immediately.

@anjulalk
Copy link
Contributor Author

anjulalk commented Jul 5, 2020

@laurent22 Accelerator for "Print" command seems to be removed in c63c637
Was this intentional? I've re-added it just in case.

@laurent22
Copy link
Owner

For some reason, the service doesn't seem to be able to access values from Setting model prior to the app initialization. That's why I moved the plugin loading after the app initialization. If GotoAnything plugin creates the service instance first, it cannot access the profile directory and fails to read the file. Hope that's okay.

I think it means that if the GotoAnything shortcut is changed it's never going to be updated, unless you restart the app. I'd say you should put the require statement where it was and make manifest.accelerator a function, so that it always get the latest value from your service.

@anjulalk anjulalk force-pushed the keymap-handler branch 2 times, most recently from 926b181 to f375718 Compare July 18, 2020 20:59
@anjulalk
Copy link
Contributor Author

Thanks for reviewing. I've made the requested changes. Please have a look if there are any issues.

Copy link
Owner

@laurent22 laurent22 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the latest changes Anjula, I think we're getting there. Please don't add GUI-related files in here to keep the pull request small (I think the one you've added was by mistake but just want to confirm).

@@ -75,6 +76,8 @@ export default class CommandService extends BaseService {
private commandPreviousStates_:CommandStates = {};
private mapStateToPropsIID_:any = null;

private keymapService = KeymapService.instance();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That should be passed as a dependency to initialize() (like the store now). This makes it easier to control the initialization order, or indeed to find bugs in the program when we realize that for example the keymap service is initialized after the command service. Dependency injection also makes it easier to test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I added an extra parameter for the KeymapService instance to the initialize() function. The instance is now created in the app.js file and passed to CommandService.

@@ -0,0 +1,106 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about that. I removed the file.

@@ -10047,11 +10047,6 @@
"through2": "^2.0.3"
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If package.json hasn't change, this file should not be included so please remove it.

Copy link
Contributor Author

@anjulalk anjulalk Jul 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is from a merge commit which had a new dependency for aws-sdk package. But I rebased on the latest commit on master so that I wouldn't need to commit this file, just to be sure..

describe('services_KeymapService', () => {
describe('validateAccelerator', () => {
it('should identify valid Accelerators', () => {
const testCases = shim.isMac()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests should not be platform-specific, because if you make changes to one platform code, the tests might pass even though something is broken in another platform. It's best to pass the platform name to the KeymapService constructor for example. You can default the value to shim.platformName() if you want, but it should be possible to set it to something else.

This comment applies to all the uses of shim.isMac in the tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing that out. I faced this exact situation situation after splitting the keymap into macOS and other platforms. What I did was either use the CI to see if tests had been passed, or boot up macOS in a VM and perform tests manually.

Providing a method to change the platform will enable it to test for both platforms at once. I'll come up with a solution to this.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes when we build the apps on CI (Travis and Appveyor) we could technically run platform-specific tests, however it's nice too to be able to run them all locally, and for that the easiest way (at least in this case) is to provide the platform to the service.

By the way, rather than changing the keymap based on a boolean (isMac) please make it change based on a string (platformName). So for now, your code would be very similar, just checking for platformName = 'darwin', but later we also have the option to add support for other platform-specific shortcuts.

@laurent22 laurent22 changed the base branch from master to dev August 1, 2020 16:59
@@ -1,4 +1,5 @@
const BaseService = require('lib/services/BaseService');
const KeymapService = require('lib/services/KeymapService.js');
Copy link
Owner

@laurent22 laurent22 Aug 1, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since KeymapService is a TypeScript file, you should import it with import KeymapService from "lib/services/KeymapService" so as to benefit from type-checking.

Comment on lines 79 to 81
private keymapService:typeof KeymapService = null;

initialize(store:any, keymapService:typeof KeymapService) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once you've updated the import statement, the "typeof KeymapService" should no longer be needed, and you can just do keymapService:KeymapService

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing that out. I'll update it right away.

this.modifiersRegExp = modifiersRegExp.default;
break;
default:
throw new Error(`Unsupported platform: ${platform}`);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why throw an exception here? It should be either "darwin" or "default". There's no need to list all the supported platforms. That way if it's a platform we don't know about it will work instead of crashing for no particular reason.

@anjulalk
Copy link
Contributor Author

anjulalk commented Aug 2, 2020

I've changed import statements and removed explicit platform checks. Please have a look and give your feedback.

@laurent22
Copy link
Owner

Thanks @anjulalk, I'm going to check, but I'm wondering about your current git workflow. Since you've used forced push I have no idea what you've changed since last time. It looks like all your commits were done 8 hours ago, even though they've been there for several months. What it means is that I cannot follow your progress - I have to start over and review everything every time.

So I'm wondering why is there a need for force push? Is that because you rebase from dev? Instead of rebasing, could you merge dev in your branch, then do a regular push? Or are there some issues that are difficult to solve without force push?

@laurent22
Copy link
Owner

Other than the force push issue it's all looking good, so let's merge. Good job Anjula!

@laurent22 laurent22 merged commit 88f22fa into laurent22:dev Aug 2, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
desktop All desktop platforms
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants