diff --git a/docs/README.md b/docs/README.md index 9af49c91..d0be1d5c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Note: If you'd like to customize any of the spoken forms, please see the [docume - [Marks](#marks) - [Decorated symbol](#decorated-symbol) - [Colors](#colors) + - [Shapes](#shapes) - [`"this"`](#this) - [`"that"`](#that) - [Modifiers](#modifiers) @@ -82,32 +83,50 @@ Combining this with an action, we might say `"take blue air"` to select the toke The following colors are supported: -| Command | Visible color | -| ---------- | ------------- | -| `"blue"` | Blue | -| `"green"` | Green | -| `"rose"` | Red | -| `"squash"` | Yellow | -| `"plum"` | Pink | +| Spoken form | Visible color | Internal ID | +| ----------- | ------------- | ----------- | +| `"blue"` | Blue | `blue` | +| `"green"` | Green | `green` | +| `"rose"` | Red | `rose` | +| `"squash"` | Yellow | `yellow` | +| `"plum"` | Pink | `pink` | -###### Shapes +You can enable or disable colors in your VSCode settings, by searching for `cursorless.hatEnablement.colors` and checking the box next to the internal ID for the given shape as listed above. -The following shapes are supported: +You can also tweak the visible colors for any of these colors in your VSCode settings, by searching for `cursorless.colors` and changing the hex color code next to the internal ID for the given shape as listed above. Note that you can configure different colors for dark and light themes. -| Command | Visible color | Enabled by default? | -| --------- | ---------------- | ------------------- | -| `"splat"` | Four-point star | ✅ | -| `"fox"` | Chevron | ✅ | -| `"wing"` | Three-point star | ❌ | -| `"hole"` | Hole | ❌ | -| `"frame"` | Frame | ❌ | -| `"curve"` | Curve | ❌ | -| `"stare"` | Eye | ❌ | +If you find these color names unintuitive / tough to remember, their +spoken forms can be [customized](customization.md) like any other spoken form +in cursorless. If you change a spoken form to be more than one syllable, you +can change the penalty in the `cursorless.hatPenalties.colors` setting to the +number of syllables you use, so that cursorless can optimize hat allocation to +minimize syllables. -To enable or disable shapes requires the following two steps: +###### Shapes + +The following shapes are supported: -1. Check the box corresponding to the given shape in the `cursorless.hatEnablement.shapes` field of the cursorless vscode settings -2. Enable the corresponding spoken form in the [spoken form customization csvs](customization.md) for cursorless talon +| Spoken form | Internal ID | Shape | Enabled by default? | +| ----------- | ----------- | ---------------------------------------------------------------------------------------------- | ------------------- | +| `"ex"` | `ex` | ![Ex](https://raw.githubusercontent.com/pokey/cursorless-vscode/main/images/hats/ex.svg) | ❌ | +| `"fox"` | `fox` | ![Fox](https://raw.githubusercontent.com/pokey/cursorless-vscode/main/images/hats/fox.svg) | ❌ | +| `"wing"` | `wing` | ![Wing](https://raw.githubusercontent.com/pokey/cursorless-vscode/main/images/hats/wing.svg) | ❌ | +| `"hole"` | `hole` | ![Hole](https://raw.githubusercontent.com/pokey/cursorless-vscode/main/images/hats/hole.svg) | ❌ | +| `"frame"` | `frame` | ![Frame](https://raw.githubusercontent.com/pokey/cursorless-vscode/main/images/hats/frame.svg) | ❌ | +| `"curve"` | `curve` | ![Curve](https://raw.githubusercontent.com/pokey/cursorless-vscode/main/images/hats/curve.svg) | ❌ | +| `"eye"` | `eye` | ![Eye](https://raw.githubusercontent.com/pokey/cursorless-vscode/main/images/hats/eye.svg) | ❌ | +| `"play"` | `play` | ![Play](https://raw.githubusercontent.com/pokey/cursorless-vscode/main/images/hats/play.svg) | ❌ | +| `"star"` | `star` | ![Star](https://raw.githubusercontent.com/pokey/cursorless-vscode/main/images/hats/star.svg) | ❌ | +| `"bolt"` | `bolt` | ![Bolt](https://raw.githubusercontent.com/pokey/cursorless-vscode/main/images/hats/bolt.svg) | ❌ | + +You can enable or disable shapes in your VSCode settings, by searching for `cursorless.hatEnablement.shapes` and checking the box next to the internal ID for the given shape as listed above. + +If you find these shape names unintuitive / tough to remember, their +spoken forms can be [customized](customization.md) like any other spoken form +in cursorless. If you change a spoken form to be more than one syllable, you +can change the penalty in the `cursorless.hatPenalties.shapes` setting to the +number of syllables you use, so that cursorless can optimize hat allocation to +minimize syllables. ##### `"this"` diff --git a/src/cheat_sheet.py b/src/cheat_sheet.py index 87913d27..f6ca6d32 100644 --- a/src/cheat_sheet.py +++ b/src/cheat_sheet.py @@ -203,13 +203,17 @@ def draw(self, canvas): }, ) - self.next_row() - self.draw_header(canvas, "Colors") - self.draw_items(canvas, get_list("hat_color")) - - self.next_row() - self.draw_header(canvas, "Shapes") - self.draw_items(canvas, get_list("hat_shape")) + hat_colors = get_list("hat_color") + if hat_colors: + self.next_row() + self.draw_header(canvas, "Colors") + self.draw_items(canvas, hat_colors) + + hat_shapes = get_list("hat_shape") + if hat_shapes: + self.next_row() + self.draw_header(canvas, "Shapes") + self.draw_items(canvas, hat_shapes) self.next_row() self.draw_header(canvas, "Examples") diff --git a/src/csv_overrides.py b/src/csv_overrides.py index 88744057..ed004b45 100644 --- a/src/csv_overrides.py +++ b/src/csv_overrides.py @@ -14,7 +14,11 @@ ) -def init_csv_and_watch_changes(filename: str, default_values: dict[str, dict]): +def init_csv_and_watch_changes( + filename: str, + default_values: dict[str, dict], + extra_acceptable_values: list[str] = None, +): """ Initialize a cursorless settings csv, creating it if necessary, and watch for changes to the csv. Talon lists will be generated based on the keys of @@ -33,7 +37,12 @@ def init_csv_and_watch_changes(filename: str, default_values: dict[str, dict]): `cursorles-settings` dir default_values (dict[str, dict]): The default values for the lists to be customized in the given csv + extra_acceptable_values list[str]: Don't throw an exception if any of + these appear as values """ + if extra_acceptable_values is None: + extra_acceptable_values = [] + dir_path, file_path = get_file_paths(filename) super_default_values = get_super_values(default_values) @@ -42,25 +51,36 @@ def init_csv_and_watch_changes(filename: str, default_values: dict[str, dict]): def on_watch(path, flags): if file_path.match(path): current_values, has_errors = read_file( - file_path, super_default_values.values() + file_path, super_default_values.values(), extra_acceptable_values ) - update_dicts(default_values, current_values) + update_dicts(default_values, current_values, extra_acceptable_values) fs.watch(dir_path, on_watch) if file_path.is_file(): - current_values = update_file(file_path, super_default_values) - update_dicts(default_values, current_values) + current_values = update_file( + file_path, super_default_values, extra_acceptable_values + ) + update_dicts(default_values, current_values, extra_acceptable_values) else: create_file(file_path, super_default_values) - update_dicts(default_values, super_default_values) + update_dicts(default_values, super_default_values, extra_acceptable_values) + + def unsubscribe(): + fs.unwatch(dir_path, on_watch) + + return unsubscribe def is_removed(value: str): return value.startswith("-") -def update_dicts(default_values: dict[str, dict], current_values: dict): +def update_dicts( + default_values: dict[str, dict], + current_values: dict, + extra_acceptable_values: list[str], +): # Create map with all default values results_map = {} for list_name, dict in default_values.items(): @@ -69,7 +89,13 @@ def update_dicts(default_values: dict[str, dict], current_values: dict): # Update result with current values for key, value in current_values.items(): - results_map[value]["key"] = key + try: + results_map[value]["key"] = key + except KeyError: + if value in extra_acceptable_values: + pass + else: + raise # Convert result map back to result list results = {key: {} for key in default_values} @@ -84,8 +110,10 @@ def update_dicts(default_values: dict[str, dict], current_values: dict): ctx.lists[get_cursorless_list_name(list_name)] = dict -def update_file(path: Path, default_values: dict): - current_values, has_errors = read_file(path, default_values.values()) +def update_file(path: Path, default_values: dict, extra_acceptable_values: list[str]): + current_values, has_errors = read_file( + path, default_values.values(), extra_acceptable_values + ) current_identifiers = current_values.values() missing = {} @@ -149,7 +177,9 @@ def csv_error(path: Path, index: int, message: str, value: str): print(f"ERROR: {path}:{index+1}: {message} '{value}'") -def read_file(path: Path, default_identifiers: list[str]): +def read_file( + path: Path, default_identifiers: list[str], extra_acceptable_values: list[str] +): with open(path) as f: lines = list(f) @@ -180,7 +210,7 @@ def read_file(path: Path, default_identifiers: list[str]): seen_header = True continue - if value not in default_identifiers: + if value not in default_identifiers and value not in extra_acceptable_values: has_errors = True csv_error(path, i, "Unknown identifier", value) continue diff --git a/src/marks/mark.py b/src/marks/mark.py index 8e4fba78..ddba246b 100644 --- a/src/marks/mark.py +++ b/src/marks/mark.py @@ -1,6 +1,7 @@ from dataclasses import dataclass +from pathlib import Path from ..conventions import get_cursorless_list_name -from talon import Module, app, Context +from talon import Module, actions, app, Context, fs, cron from ..csv_overrides import init_csv_and_watch_changes mod = Module() @@ -21,13 +22,16 @@ } hat_shapes = { - "splat": "fourPointStar", - "fox": "chevron", - "-wing": "threePointStar", - "-hole": "hole", - "-frame": "frame", - "-curve": "curve", - "-stare": "eye", + "ex": "ex", + "fox": "fox", + "wing": "wing", + "hole": "hole", + "frame": "frame", + "curve": "curve", + "eye": "eye", + "play": "play", + "star": "star", + "bolt": "bolt", } @@ -97,20 +101,61 @@ def cursorless_mark(m) -> str: return m.cursorless_line_number_simple -def on_ready(): - init_csv_and_watch_changes( - "special_marks", +unsubscribe_hat_styles = None + + +color_enablements = set() +shape_enablements = set() + + +def setup_hat_styles_csv(enablements: dict): + global unsubscribe_hat_styles, color_enablements, shape_enablements + + new_color_enablements = set(enablements["colors"]) + new_shape_enablements = set(enablements["shapes"]) + + if ( + color_enablements == new_color_enablements + and shape_enablements == new_shape_enablements + ): + return + + shape_enablements = new_shape_enablements + color_enablements = new_color_enablements + + active_hat_colors = { + spoken_form: value + for spoken_form, value in hat_colors.items() + if value in enablements["colors"] + } + active_hat_shapes = { + spoken_form: value + for spoken_form, value in hat_shapes.items() + if value in enablements["shapes"] + } + + if unsubscribe_hat_styles is not None: + unsubscribe_hat_styles() + + unsubscribe_hat_styles = init_csv_and_watch_changes( + "hat_styles", { - "special_mark": special_marks_defaults, + "hat_color": active_hat_colors, + "hat_shape": active_hat_shapes, }, + [*hat_colors.values(), *hat_shapes.values()], ) + + +def on_ready(): init_csv_and_watch_changes( - "hat_styles", + "special_marks", { - "hat_color": hat_colors, - "hat_shape": hat_shapes, + "special_mark": special_marks_defaults, }, ) + actions.user.watch_vscode_state("cursorless.hatEnablement", setup_hat_styles_csv) + app.register("ready", on_ready) diff --git a/src/marks/vscode_settings.py b/src/marks/vscode_settings.py new file mode 100644 index 00000000..43b0954a --- /dev/null +++ b/src/marks/vscode_settings.py @@ -0,0 +1,58 @@ +import json +import os +from talon import Context, Module, actions, cron +from pathlib import Path +from ..vendor.jstyleson import loads + +mod = Module() + +windows_ctx = Context() +mac_ctx = Context() +linux_ctx = Context() + +windows_ctx.matches = r""" +os: windows +""" +mac_ctx.matches = r""" +os: mac +""" +linux_ctx.matches = r""" +os: linux +""" + + +@mod.action_class +class Actions: + def vscode_settings_path() -> Path: + """Get path of vscode settings json file""" + pass + + def vscode_get_setting(key: str, default_value: any = None): + """Get the value of vscode setting at the given key""" + path: Path = actions.user.vscode_settings_path() + settings: dict = loads(path.read_text()) + + if default_value is not None: + return settings.get(key, default_value) + else: + return settings[key] + + +@mac_ctx.action_class("user") +class MacUserActions: + def vscode_settings_path() -> Path: + return Path( + f"{os.environ['HOME']}/Library/Application Support/Code/User/settings.json" + ) + + +@linux_ctx.action_class("user") +class LinuxUserActions: + def vscode_settings_path() -> Path: + return Path(f"{os.environ['HOME']}/.config/Code/User/settings.json") + + +@windows_ctx.action_class("user") +class WindowsUserActions: + def vscode_settings_path() -> Path: + return Path(f"{os.environ['APPDATA']}/Code/User/settings.json") diff --git a/src/vendor/__init__.py b/src/vendor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/vendor/jstyleson.py b/src/vendor/jstyleson.py new file mode 100644 index 00000000..fe1bbf1c --- /dev/null +++ b/src/vendor/jstyleson.py @@ -0,0 +1,137 @@ +# From https://github.com/linjackson78/jstyleson/blob/8c47cc9e665b3b1744cccfaa7a650de5f3c575dd/jstyleson.py +# License https://github.com/linjackson78/jstyleson/blob/8c47cc9e665b3b1744cccfaa7a650de5f3c575dd/LICENSE +import json + + +def dispose(json_str): + """Clear all comments in json_str. + + Clear JS-style comments like // and /**/ in json_str. + Accept a str or unicode as input. + + Args: + json_str: A json string of str or unicode to clean up comment + + Returns: + str: The str without comments (or unicode if you pass in unicode) + """ + result_str = list(json_str) + escaped = False + normal = True + sl_comment = False + ml_comment = False + quoted = False + + a_step_from_comment = False + a_step_from_comment_away = False + + former_index = None + + for index, char in enumerate(json_str): + + if escaped: # We have just met a '\' + escaped = False + continue + + if a_step_from_comment: # We have just met a '/' + if char != '/' and char != '*': + a_step_from_comment = False + normal = True + continue + + if a_step_from_comment_away: # We have just met a '*' + if char != '/': + a_step_from_comment_away = False + + if char == '"': + if normal and not escaped: + # We are now in a string + quoted = True + normal = False + elif quoted and not escaped: + # We are now out of a string + quoted = False + normal = True + + elif char == '\\': + # '\' should not take effect in comment + if normal or quoted: + escaped = True + + elif char == '/': + if a_step_from_comment: + # Now we are in single line comment + a_step_from_comment = False + sl_comment = True + normal = False + former_index = index - 1 + elif a_step_from_comment_away: + # Now we are out of comment + a_step_from_comment_away = False + normal = True + ml_comment = False + for i in range(former_index, index + 1): + result_str[i] = "" + + elif normal: + # Now we are just one step away from comment + a_step_from_comment = True + normal = False + + elif char == '*': + if a_step_from_comment: + # We are now in multi-line comment + a_step_from_comment = False + ml_comment = True + normal = False + former_index = index - 1 + elif ml_comment: + a_step_from_comment_away = True + elif char == '\n': + if sl_comment: + sl_comment = False + normal = True + for i in range(former_index, index + 1): + result_str[i] = "" + elif char == ']' or char == '}': + if normal: + _remove_last_comma(result_str, index) + + # To remove single line comment which is the last line of json + if sl_comment: + sl_comment = False + normal = True + for i in range(former_index, len(json_str)): + result_str[i] = "" + + # Show respect to original input if we are in python2 + return ("" if isinstance(json_str, str) else u"").join(result_str) + + +# There may be performance suffer backtracking the last comma +def _remove_last_comma(str_list, before_index): + i = before_index - 1 + while str_list[i].isspace() or not str_list[i]: + i -= 1 + + # This is the first none space char before before_index + if str_list[i] == ',': + str_list[i] = '' + + +# Below are just some wrapper function around the standard json module. + +def loads(text, **kwargs): + return json.loads(dispose(text), **kwargs) + + +def load(fp, **kwargs): + return loads(fp.read(), **kwargs) + + +def dumps(obj, **kwargs): + return json.dumps(obj, **kwargs) + + +def dump(obj, fp, **kwargs): + json.dump(obj, fp, **kwargs) \ No newline at end of file