diff --git a/docs/developer-file-overview.md b/docs/developer-file-overview.md index ef21a7c9fb..89887591c1 100644 --- a/docs/developer-file-overview.md +++ b/docs/developer-file-overview.md @@ -8,6 +8,7 @@ Zulip Terminal uses [Zulip's API](https://zulip.com/api/) to store and retrieve | Folder | File | Description | | ---------------------- | ------------------- | ----------------------------------------------------------------------------------------| | zulipterminal | api_types.py | Types from the Zulip API, translated into python, to improve type checking | +| | contexts.py | Tracks the currently focused widget in the UI | | | core.py | Defines the `Controller`, which sets up the `Model`, `View`, and how they interact | | | helper.py | Helper functions used in multiple places | | | model.py | Defines the `Model`, fetching and storing data retrieved from the Zulip server | diff --git a/docs/hotkeys.md b/docs/hotkeys.md index 86162271fd..056450a0ae 100644 --- a/docs/hotkeys.md +++ b/docs/hotkeys.md @@ -6,6 +6,7 @@ |Command|Key Combination| | :--- | :---: | |Show/hide Help Menu|?| +|Show/hide Contextual Help Menu|Meta + c| |Show/hide Markdown Help Menu|Meta + m| |Show/hide About Menu|Meta + ?| |Copy information from About Menu to clipboard|c| diff --git a/tests/config/test_keys.py b/tests/config/test_keys.py index 752e69af29..534c349cef 100644 --- a/tests/config/test_keys.py +++ b/tests/config/test_keys.py @@ -67,40 +67,61 @@ def test_is_command_key_invalid_command(invalid_command: str) -> None: def test_HELP_is_not_allowed_as_tip() -> None: assert keys.KEY_BINDINGS["HELP"]["excluded_from_random_tips"] is True - assert keys.KEY_BINDINGS["HELP"] not in keys.commands_for_random_tips() + commands, _ = keys.commands_for_random_tips() + assert keys.KEY_BINDINGS["HELP"] not in commands -def test_commands_for_random_tips(mocker: MockerFixture) -> None: +@pytest.mark.parametrize( + "context, expected_command, expected_context", + [ + (None, "GAMMA", "Global"), + ("context_1", "BETA", "Context 1"), + ("context_2", "GAMMA", "Global"), + ], +) +def test_commands_for_random_tips( + context: str, expected_command: str, expected_context: str, mocker: MockerFixture +) -> None: new_key_bindings: Dict[str, keys.KeyBinding] = { "ALPHA": { "keys": ["a"], "help_text": "alpha", "key_category": "category 1", + "key_contexts": ["context_1", "context_2"], "excluded_from_random_tips": True, }, "BETA": { "keys": ["b"], "help_text": "beta", "key_category": "category 1", + "key_contexts": ["context_1"], "excluded_from_random_tips": False, }, "GAMMA": { "keys": ["g"], "help_text": "gamma", "key_category": "category 1", + "key_contexts": ["global"], }, "DELTA": { "keys": ["d"], "help_text": "delta", "key_category": "category 2", + "key_contexts": ["context_2"], "excluded_from_random_tips": True, }, } + new_help_contexts: Dict[str, str] = { + "global": "Global", + "context_1": "Context 1", + "context_2": "Context 2", + } mocker.patch.dict(keys.KEY_BINDINGS, new_key_bindings, clear=True) - result = keys.commands_for_random_tips() - assert len(result) == 2 - assert new_key_bindings["BETA"] in result - assert new_key_bindings["GAMMA"] in result + mocker.patch.object(keys, "HELP_CONTEXTS", new_help_contexts) + commands, context_display_name = keys.commands_for_random_tips(context) + assert len(commands) == 1 + assert new_key_bindings[expected_command] in commands + assert context_display_name == expected_context def test_updated_urwid_command_map() -> None: diff --git a/tests/core/test_core.py b/tests/core/test_core.py index ff6701a41d..896a264ee6 100644 --- a/tests/core/test_core.py +++ b/tests/core/test_core.py @@ -39,7 +39,7 @@ def controller(self, mocker: MockerFixture) -> Controller: self.poll_for_events = mocker.patch(MODEL + ".poll_for_events") mocker.patch(MODULE + ".Controller.show_loading") self.main_loop = mocker.patch( - MODULE + ".urwid.MainLoop", return_value=mocker.Mock() + MODULE + ".FocusTrackingMainLoop", return_value=mocker.Mock() ) self.config_file = "path/to/zuliprc" @@ -589,7 +589,8 @@ def test_show_typing_notification( controller: Controller, active_conversation_info: Dict[str, str], ) -> None: - set_footer_text = mocker.patch(VIEW + ".set_footer_text") + set_footer_text_for_event = mocker.patch(VIEW + ".set_footer_text_for_event") + reset_footer_text = mocker.patch(VIEW + ".reset_footer_text") mocker.patch(MODULE + ".time.sleep") controller.active_conversation_info = active_conversation_info @@ -600,7 +601,7 @@ def mock_typing() -> None: Thread(controller.show_typing_notification()).start() if active_conversation_info: - set_footer_text.assert_has_calls( + set_footer_text_for_event.assert_has_calls( [ mocker.call([("footer_contrast", " hamlet "), " is typing"]), mocker.call([("footer_contrast", " hamlet "), " is typing."]), @@ -608,8 +609,8 @@ def mock_typing() -> None: mocker.call([("footer_contrast", " hamlet "), " is typing..."]), ] ) - set_footer_text.assert_called_with() + reset_footer_text.assert_called_with() else: - set_footer_text.assert_called_once_with() + reset_footer_text.assert_called_once_with() assert controller.is_typing_notification_in_progress is False assert controller.active_conversation_info == {} diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index d017f4697a..80adcfca3d 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -85,39 +85,60 @@ def test_set_footer_text_same_test( view._w.footer.set_text.assert_not_called() - def test_set_footer_text_default(self, view: View, mocker: MockerFixture) -> None: + def test_reset_footer_text(self, view: View, mocker: MockerFixture) -> None: mocker.patch(VIEW + ".get_random_help", return_value=["some help text"]) - view.set_footer_text() + view.reset_footer_text() view.frame.footer.set_text.assert_called_once_with(["some help text"]) view.controller.update_screen.assert_called_once_with() + assert view._is_footer_event_running is False def test_set_footer_text_specific_text( self, view: View, text: str = "blah" ) -> None: - view.set_footer_text([text]) + view.set_footer_text_for_event([text]) view.frame.footer.set_text.assert_called_once_with([text]) view.controller.update_screen.assert_called_once_with() + assert view._is_footer_event_running is True def test_set_footer_text_with_duration( self, view: View, mocker: MockerFixture, custom_text: str = "custom", - duration: Optional[float] = 5.3, + duration: float = 5.3, ) -> None: mocker.patch(VIEW + ".get_random_help", return_value=["some help text"]) mock_sleep = mocker.patch("time.sleep") - view.set_footer_text([custom_text], duration=duration) + view.set_footer_text_for_event_duration([custom_text], duration=duration) view.frame.footer.set_text.assert_has_calls( [mocker.call([custom_text]), mocker.call(["some help text"])] ) mock_sleep.assert_called_once_with(duration) assert view.controller.update_screen.call_count == 2 + assert view._is_footer_event_running is False + + @pytest.mark.parametrize( + "event_running, expected_call_count", [(True, 0), (False, 1)] + ) + def test_set_footer_text_on_context_change( + self, + view: View, + mocker: MockerFixture, + event_running: bool, + expected_call_count: int, + ) -> None: + mocker.patch(VIEW + ".get_random_help", return_value=["some help text"]) + view._is_footer_event_running = event_running + + view.set_footer_text_on_context_change() + + assert view.frame.footer.set_text.call_count == expected_call_count + assert view.controller.update_screen.call_count == expected_call_count @pytest.mark.parametrize( "suggestions, state, truncated, footer_text", @@ -350,12 +371,12 @@ def test_keypress_NEW_HINT( widget_size: Callable[[Widget], urwid_Box], ) -> None: size = widget_size(view) - set_footer_text = mocker.patch(VIEW + ".set_footer_text") + reset_footer_text = mocker.patch(VIEW + ".reset_footer_text") mocker.patch(CONTROLLER + ".is_in_editor_mode", return_value=False) returned_key = view.keypress(size, key) - set_footer_text.assert_called_once_with() + reset_footer_text.assert_called_once_with() assert returned_key == key @pytest.mark.parametrize("key", keys_for_command("SEARCH_PEOPLE")) diff --git a/tests/ui_tools/test_boxes.py b/tests/ui_tools/test_boxes.py index c4e89a2b58..c24c263d2c 100644 --- a/tests/ui_tools/test_boxes.py +++ b/tests/ui_tools/test_boxes.py @@ -1583,7 +1583,7 @@ def test__keypress_typeahead_mode_autocomplete_key_footer_no_reset( write_box.keypress(size, key) assert write_box.is_in_typeahead_mode == expected_typeahead_mode - assert not self.view.set_footer_text.called + assert not self.view.reset_footer_text.called @pytest.mark.parametrize( "key, current_typeahead_mode, expected_typeahead_mode", @@ -1611,7 +1611,7 @@ def test__keypress_typeahead_mode_autocomplete_key_footer_reset( assert write_box.is_in_typeahead_mode == expected_typeahead_mode # We may prefer called-once in future, but the key part is that we do reset - assert self.view.set_footer_text.called + assert self.view.reset_footer_text.called @pytest.mark.parametrize( [ diff --git a/tools/lint-hotkeys b/tools/lint-hotkeys index 83a7db068e..1005f33421 100755 --- a/tools/lint-hotkeys +++ b/tools/lint-hotkeys @@ -1,14 +1,17 @@ #!/usr/bin/env python3 import argparse import re -import sys from collections import defaultdict from pathlib import Path, PurePath -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Union + +from typing_extensions import Literal, TypedDict from zulipterminal.config.keys import ( HELP_CATEGORIES, + HELP_CONTEXTS, KEY_BINDINGS, + PARENT_CONTEXTS, display_keys_for_command, ) @@ -17,8 +20,8 @@ KEYS_FILE = ( Path(__file__).resolve().parent.parent / "zulipterminal" / "config" / "keys.py" ) KEYS_FILE_NAME = KEYS_FILE.name -OUTPUT_FILE = Path(__file__).resolve().parent.parent / "docs" / "hotkeys.md" -OUTPUT_FILE_NAME = OUTPUT_FILE.name +CATEGORY_OUTPUT_FILE = Path(__file__).resolve().parent.parent / "docs" / "hotkeys.md" +CONTEXT_OUTPUT_FILE = Path(__file__).resolve().parent.parent / "docs" / "contexts.md" SCRIPT_NAME = PurePath(__file__).name HELP_TEXT_STYLE = re.compile(r"^[a-zA-Z /()',&@#:_-]*$") @@ -26,35 +29,130 @@ HELP_TEXT_STYLE = re.compile(r"^[a-zA-Z /()',&@#:_-]*$") KEYS_TO_EXCLUDE = ["q", "e", "m", "r", "Esc"] -def main(fix: bool) -> None: +Group = Literal["category", "context"] +KeyGroup = Literal["key_category", "key_contexts"] +GroupedHelpEntries = Dict[str, List[Tuple[str, List[str]]]] + + +def read_help_groups(key_group: KeyGroup) -> GroupedHelpEntries: + """ + Generate a dict from KEYS_FILE + key: help category/context + value: a list of help texts and key combinations, for each key binding in that help category/context + """ + entries_by_group = defaultdict(list) + for cmd, item in KEY_BINDINGS.items(): + groups = [item[key_group]] if key_group == "key_category" else item[key_group] + for group in groups: + entries_by_group[group].append( + (item["help_text"], display_keys_for_command(cmd)) + ) + return entries_by_group + + +class HelpGroupBundle(TypedDict): + help_group: Dict[str, str] + entries_by_group: GroupedHelpEntries + key_group: KeyGroup + output_file: Path + + +GROUP_MAPPING: Dict[Group, HelpGroupBundle] = { + "category": { + "help_group": HELP_CATEGORIES, + "entries_by_group": read_help_groups("key_category"), + "key_group": "key_category", + "output_file": CATEGORY_OUTPUT_FILE, + }, + "context": { + "help_group": HELP_CONTEXTS, + "entries_by_group": read_help_groups("key_contexts"), + "key_group": "key_contexts", + "output_file": CONTEXT_OUTPUT_FILE, + }, +} + + +def main(fix: bool, generate_context_file: bool) -> None: if fix: - generate_hotkeys_file() - else: + generate_hotkeys_file("category") + if generate_context_file: + generate_hotkeys_file("context") + if not fix and not generate_context_file: lint_hotkeys_file() def lint_hotkeys_file() -> None: """ - Lint KEYS_FILE for key description, then compare if in sync with - existing OUTPUT_FILE + Lint KEYS_FILE for valid key descriptions (help texts) and key categories/contexts, + check for duplicate key combinations, + and compare with existing output file. + """ + error_flag = False + + error_flag |= ( + lint_help_groups("category") + | lint_help_groups("context") + | lint_help_text() + | lint_parent_contexts() + ) + + if error_flag: + raise SystemExit( + f"Rerun this command after resolving errors in config/{KEYS_FILE_NAME}" + ) + else: + print("No hotkeys linting errors") + output_file = CATEGORY_OUTPUT_FILE + if not output_file.exists(): + raise SystemExit( + f"Run './tools/{SCRIPT_NAME} --fix' to generate {output_file.name} file" + ) + hotkeys_file_string = generate_hotkeys_file_string("category") + if not output_file_matches_string(hotkeys_file_string, "category"): + raise SystemExit( + f"Run './tools/{SCRIPT_NAME} --fix' to update {output_file.name} file" + ) + + +def lint_help_text() -> bool: + """ + Lint each keybinding's help text / description for invalid characters + """ + error_flag = False + error_message = "" + for keybinding in KEY_BINDINGS.values(): + help_text = keybinding["help_text"] + if not re.match(HELP_TEXT_STYLE, help_text): + key_combinations = " / ".join(keybinding["keys"]) + error_message += ( + f" ({help_text}) for key combination - [{key_combinations}]\n" + ) + error_flag = True + if error_flag: + print( + "Help text descriptions should contain only alphabets, spaces and special characters except .\n" + + error_message + ) + return error_flag + + +def lint_help_groups(group: Group) -> bool: + """ + Lint help groups by checking each key combination for duplicates + within the same group and the validity of each category/context (typo-checking) """ - hotkeys_file_string = get_hotkeys_file_string() - # To lint keys description - error_flag = 0 - categories = read_help_categories() - for action in HELP_CATEGORIES: + bundle = GROUP_MAPPING[group] + help_group = bundle["help_group"] + entries_by_group = bundle["entries_by_group"] + key_group: KeyGroup = bundle["key_group"] + + # Lint for duplicate key combinations within the same category/context + error_flag = False + for batch in help_group: check_duplicate_keys_list: List[str] = [] - for help_text, key_combinations_list in categories[action]: + for _, key_combinations_list in entries_by_group[batch]: check_duplicate_keys_list.extend(key_combinations_list) - various_key_combinations = " / ".join(key_combinations_list) - # Check description style - if not re.match(HELP_TEXT_STYLE, help_text): - print( - f"Description - ({help_text}) for key combination - [{various_key_combinations}]\n" - "It should contain only alphabets, spaces and special characters except ." - ) - error_flag = 1 - # Check key combination duplication check_duplicate_keys_list = [ key for key in check_duplicate_keys_list if key not in KEYS_TO_EXCLUDE ] @@ -65,49 +163,93 @@ def lint_hotkeys_file() -> None: ] if len(duplicate_keys) != 0: print( - f"Duplicate key combination for keys {duplicate_keys} for category ({HELP_CATEGORIES[action]}) detected" - ) - error_flag = 1 - if error_flag == 1: - print(f"Rerun this command after resolving errors in config/{KEYS_FILE_NAME}") - else: - print("No hotkeys linting errors") - if not output_file_matches_string(hotkeys_file_string): - print( - f"Run './tools/{SCRIPT_NAME} --fix' to update {OUTPUT_FILE_NAME} file" + f"Duplicate key combination for keys {duplicate_keys} for {group} ({help_group[batch]}) detected\n" ) - error_flag = 1 - sys.exit(error_flag) + error_flag = True + + # Lint for typos in key categories/contexts + error_message = "" + for key, binding in KEY_BINDINGS.items(): + group_values: Union[str, List[str]] = binding[key_group] + if isinstance(group_values, str): + group_values = [group_values] + for group_value in group_values: + if group_value not in help_group: + error_message += f" Invalid {group} '{group_value}' for key '{key}'.\n" + error_flag = True + if error_message: + print( + f"Choose a valid {group} value from:\n{', '.join(help_group.keys())}\n" + + error_message + ) + + return error_flag + + +def lint_parent_contexts() -> bool: + """ + Lint for any typos in the PARENT_CONTEXTS dict + """ + key_typos = [] + value_typos = [] + + for key, value_list in PARENT_CONTEXTS.items(): + if key not in HELP_CONTEXTS: + key_typos.append(key) + for value in value_list: + if value not in HELP_CONTEXTS: + value_typos.append(value) + error_message = "" + if key_typos: + error_message += ( + f"Invalid contexts in parent context keys: {', '.join(key_typos)}.\n" + ) + if value_typos: + error_message += ( + f"Invalid contexts in parent context values: {', '.join(value_typos)}.\n" + ) + + if error_message: + error_message += f" Choose a context from:\n{', '.join(HELP_CONTEXTS.keys())}\n" + print(error_message) + return True + + return False -def generate_hotkeys_file() -> None: + +def generate_hotkeys_file(group: Group) -> None: """ - Generate OUTPUT_FILE based on help text description and - shortcut key combinations in KEYS_FILE + Generate output file based on help text description and + shortcut key combinations in KEYS_FILE, grouped by categories/contexts """ - hotkeys_file_string = get_hotkeys_file_string() - output_file_matches_string(hotkeys_file_string) - write_hotkeys_file(hotkeys_file_string) - print(f"Hot Keys list saved in {OUTPUT_FILE}") + output_file = GROUP_MAPPING[group]["output_file"] + hotkeys_file_string = generate_hotkeys_file_string(group) + output_file_matches_string(hotkeys_file_string, group) + with open(output_file, "w") as hotkeys_file: + hotkeys_file.write(hotkeys_file_string) + print(f"Hot Keys list saved in {output_file.name}") -def get_hotkeys_file_string() -> str: +def generate_hotkeys_file_string(group: Group) -> str: """ - Construct string in form for output to OUTPUT_FILE based on help text + Construct string in form for output to output file based on help text description and shortcut key combinations in KEYS_FILE """ - categories = read_help_categories() + help_group = GROUP_MAPPING[group]["help_group"] + entries_by_group = GROUP_MAPPING[group]["entries_by_group"] + hotkeys_file_string = ( f"\n" "\n\n# Hot Keys\n" ) - for action in HELP_CATEGORIES: + for batch in help_group: hotkeys_file_string += ( - f"## {HELP_CATEGORIES[action]}\n" + f"## {help_group[batch]}\n" "|Command|Key Combination|\n" "| :--- | :---: |\n" ) - for help_text, key_combinations_list in categories[action]: + for help_text, key_combinations_list in entries_by_group[batch]: various_key_combinations = " / ".join( [ " + ".join([f"{key}" for key in key_combination.split()]) @@ -119,35 +261,26 @@ def get_hotkeys_file_string() -> str: return hotkeys_file_string -def output_file_matches_string(hotkeys_file_string: str) -> bool: - with open(OUTPUT_FILE) as output_file: - content_is_identical = hotkeys_file_string == output_file.read() - if content_is_identical: - print(f"{OUTPUT_FILE_NAME} file already in sync with config/{KEYS_FILE_NAME}") - return True - else: - print(f"{OUTPUT_FILE_NAME} file not in sync with config/{KEYS_FILE_NAME}") - return False - - -def read_help_categories() -> Dict[str, List[Tuple[str, List[str]]]]: - """ - Get all help categories from KEYS_FILE - """ - categories = defaultdict(list) - for cmd, item in KEY_BINDINGS.items(): - categories[item["key_category"]].append( - (item["help_text"], display_keys_for_command(cmd)) - ) - return categories - - -def write_hotkeys_file(hotkeys_file_string: str) -> None: +def output_file_matches_string(hotkeys_file_string: str, group: Group) -> bool: """ - Write hotkeys_file_string variable once to OUTPUT_FILE + Check if the output file exists and matches the generated hotkeys_file_string """ - with open(OUTPUT_FILE, "w") as hotkeys_file: - hotkeys_file.write(hotkeys_file_string) + output_file = GROUP_MAPPING[group]["output_file"] + output_file_name = output_file.name + try: + with open(output_file) as existing_file: + content_is_identical = hotkeys_file_string == existing_file.read() + if content_is_identical: + print( + f"{output_file_name} file already in sync with config/{KEYS_FILE_NAME}" + ) + return True + else: + print(f"{output_file_name} file not in sync with config/{KEYS_FILE_NAME}") + return False + except FileNotFoundError: + print(f"{output_file_name} does not exist") + return False if __name__ == "__main__": @@ -158,8 +291,14 @@ if __name__ == "__main__": parser.add_argument( "--fix", action="store_true", - help=f"Generate {OUTPUT_FILE_NAME} file by extracting key description and key " + help=f"Generate {CATEGORY_OUTPUT_FILE.name} file by extracting key description and key " + f"combination from config/{KEYS_FILE_NAME} file", + ) + parser.add_argument( + "--generate-context-file", + action="store_true", + help=f"Generate {CONTEXT_OUTPUT_FILE.name} file by extracting key description and key " f"combination from config/{KEYS_FILE_NAME} file", ) args = parser.parse_args() - main(args.fix) + main(args.fix, args.generate_context_file) diff --git a/zulipterminal/config/keys.py b/zulipterminal/config/keys.py index 2e9fa40b79..65cceb62fc 100644 --- a/zulipterminal/config/keys.py +++ b/zulipterminal/config/keys.py @@ -2,7 +2,7 @@ Keybindings and their helper functions """ -from typing import Dict, List +from typing import Dict, List, Tuple from typing_extensions import NotRequired, TypedDict from urwid.command_map import ( @@ -23,6 +23,7 @@ class KeyBinding(TypedDict): help_text: str excluded_from_random_tips: NotRequired[bool] key_category: str + key_contexts: List[str] # fmt: off @@ -35,390 +36,474 @@ class KeyBinding(TypedDict): 'help_text': 'Show/hide Help Menu', 'excluded_from_random_tips': True, 'key_category': 'general', + 'key_contexts': ['general'], + }, + 'CONTEXTUAL_HELP': { + 'keys': ['meta c'], + 'help_text': 'Show/hide Contextual Help Menu', + 'excluded_from_random_tips': True, + 'key_category': 'general', + 'key_contexts': ['general'], }, 'MARKDOWN_HELP': { 'keys': ['meta m'], 'help_text': 'Show/hide Markdown Help Menu', 'key_category': 'general', + 'key_contexts': ['general'], }, 'ABOUT': { 'keys': ['meta ?'], 'help_text': 'Show/hide About Menu', 'key_category': 'general', + 'key_contexts': ['general'], }, 'OPEN_DRAFT': { 'keys': ['d'], 'help_text': 'Open draft message saved in this session', 'key_category': 'open_compose', + 'key_contexts': ['general'], }, 'COPY_ABOUT_INFO': { 'keys': ['c'], 'help_text': 'Copy information from About Menu to clipboard', 'key_category': 'general', + 'key_contexts': ['about'], }, 'EXIT_POPUP': { 'keys': ['esc'], 'help_text': 'Close popup', 'key_category': 'navigation', + 'key_contexts': ['popup'], }, 'GO_UP': { 'keys': ['up', 'k'], 'help_text': 'Go up / Previous message', 'key_category': 'navigation', + 'key_contexts': ['general'], }, 'GO_DOWN': { 'keys': ['down', 'j'], 'help_text': 'Go down / Next message', 'key_category': 'navigation', + 'key_contexts': ['general'], }, 'GO_LEFT': { 'keys': ['left', 'h'], 'help_text': 'Go left', 'key_category': 'navigation', + 'key_contexts': ['general'], }, 'GO_RIGHT': { 'keys': ['right', 'l'], 'help_text': 'Go right', 'key_category': 'navigation', + 'key_contexts': ['general'], }, 'SCROLL_UP': { 'keys': ['page up', 'K'], 'help_text': 'Scroll up', 'key_category': 'navigation', + 'key_contexts': ['general'], }, 'SCROLL_DOWN': { 'keys': ['page down', 'J'], 'help_text': 'Scroll down', 'key_category': 'navigation', + 'key_contexts': ['general'], }, 'GO_TO_BOTTOM': { 'keys': ['end', 'G'], 'help_text': 'Go to bottom / Last message', 'key_category': 'navigation', + 'key_contexts': ['general'], }, 'ACTIVATE_BUTTON': { 'keys': ['enter', ' '], 'help_text': 'Trigger the selected entry', 'key_category': 'navigation', + 'key_contexts': ['button'], }, 'REPLY_MESSAGE': { 'keys': ['r', 'enter'], 'help_text': 'Reply to the current message', 'key_category': 'open_compose', + 'key_contexts': ['message'], }, 'MENTION_REPLY': { 'keys': ['@'], 'help_text': 'Reply mentioning the sender of the current message', 'key_category': 'open_compose', + 'key_contexts': ['message'], }, 'QUOTE_REPLY': { 'keys': ['>'], 'help_text': 'Reply quoting the current message text', 'key_category': 'open_compose', + 'key_contexts': ['message'], }, 'REPLY_AUTHOR': { 'keys': ['R'], 'help_text': 'Reply directly to the sender of the current message', 'key_category': 'open_compose', + 'key_contexts': ['message'], }, 'EDIT_MESSAGE': { 'keys': ['e'], 'help_text': "Edit message's content or topic", - 'key_category': 'msg_actions' + 'key_category': 'msg_actions', + 'key_contexts': ['message'], }, 'STREAM_MESSAGE': { 'keys': ['c'], 'help_text': 'New message to a stream', 'key_category': 'open_compose', + 'key_contexts': ['general'], }, 'PRIVATE_MESSAGE': { 'keys': ['x'], 'help_text': 'New message to a person or group of people', 'key_category': 'open_compose', + 'key_contexts': ['general'], }, 'CYCLE_COMPOSE_FOCUS': { 'keys': ['tab'], 'help_text': 'Cycle through recipient and content boxes', 'key_category': 'compose_box', + 'key_contexts': ['compose_box'], }, 'SEND_MESSAGE': { 'keys': ['ctrl d', 'meta enter'], 'help_text': 'Send a message', 'key_category': 'compose_box', + 'key_contexts': ['compose_box'], }, 'SAVE_AS_DRAFT': { 'keys': ['meta s'], 'help_text': 'Save current message as a draft', 'key_category': 'compose_box', + 'key_contexts': ['compose_box'], }, 'AUTOCOMPLETE': { 'keys': ['ctrl f'], 'help_text': ('Autocomplete @mentions, #stream_names, :emoji:' ' and topics'), 'key_category': 'compose_box', + 'key_contexts': ['compose_box'], }, 'AUTOCOMPLETE_REVERSE': { 'keys': ['ctrl r'], 'help_text': 'Cycle through autocomplete suggestions in reverse', 'key_category': 'compose_box', + 'key_contexts': ['compose_box'], }, 'ADD_REACTION': { 'keys': [':'], 'help_text': 'Show/hide emoji picker for current message', 'key_category': 'msg_actions', + 'key_contexts': ['message'], }, 'STREAM_NARROW': { 'keys': ['s'], 'help_text': 'View the stream of the current message', 'key_category': 'narrowing', + 'key_contexts': ['message'], }, 'TOPIC_NARROW': { 'keys': ['S'], 'help_text': 'View the topic of the current message', 'key_category': 'narrowing', + 'key_contexts': ['message'], }, 'TOGGLE_NARROW': { 'keys': ['z'], 'help_text': "Zoom in/out the message's conversation context", 'key_category': 'narrowing', + 'key_contexts': ['message'], }, 'NARROW_MESSAGE_RECIPIENT': { 'keys': ['meta .'], 'help_text': 'Switch message view to the compose box target', 'key_category': 'narrowing', + 'key_contexts': ['compose_box'], }, 'EXIT_COMPOSE': { 'keys': ['esc'], 'help_text': 'Exit message compose box', 'key_category': 'compose_box', + 'key_contexts': ['compose_box'], }, 'REACTION_AGREEMENT': { 'keys': ['='], 'help_text': 'Toggle first emoji reaction on selected message', 'key_category': 'msg_actions', + 'key_contexts': ['message'], }, 'TOGGLE_TOPIC': { 'keys': ['t'], 'help_text': 'Toggle topics in a stream', 'key_category': 'stream_list', + 'key_contexts': ['stream', 'topic'], }, 'ALL_MESSAGES': { 'keys': ['a', 'esc'], 'help_text': 'View all messages', 'key_category': 'narrowing', + 'key_contexts': ['message'], }, 'ALL_PM': { 'keys': ['P'], 'help_text': 'View all direct messages', 'key_category': 'narrowing', + 'key_contexts': ['general'], }, 'ALL_STARRED': { 'keys': ['f'], 'help_text': 'View all starred messages', 'key_category': 'narrowing', + 'key_contexts': ['general'], }, 'ALL_MENTIONS': { 'keys': ['#'], 'help_text': "View all messages in which you're mentioned", 'key_category': 'narrowing', + 'key_contexts': ['general'], }, 'NEXT_UNREAD_TOPIC': { 'keys': ['n'], 'help_text': 'Next unread topic', 'key_category': 'narrowing', + 'key_contexts': ['general'], }, 'NEXT_UNREAD_PM': { 'keys': ['p'], 'help_text': 'Next unread direct message', 'key_category': 'narrowing', + 'key_contexts': ['general'], }, 'SEARCH_PEOPLE': { 'keys': ['w'], 'help_text': 'Search users', 'key_category': 'searching', + 'key_contexts': ['general'], }, 'SEARCH_MESSAGES': { 'keys': ['/'], 'help_text': 'Search messages', 'key_category': 'searching', + 'key_contexts': ['general'], }, 'SEARCH_STREAMS': { 'keys': ['q'], 'help_text': 'Search streams', 'key_category': 'searching', + 'key_contexts': ['general'], + 'excluded_from_random_tips': True, + # TODO: condition check required }, 'SEARCH_TOPICS': { 'keys': ['q'], 'help_text': 'Search topics in a stream', 'key_category': 'searching', + 'key_contexts': ['general'], + 'excluded_from_random_tips': True, + # TODO: condition check required }, 'SEARCH_EMOJIS': { 'keys': ['p'], 'help_text': 'Search emojis from emoji picker', - 'excluded_from_random_tips': True, 'key_category': 'searching', + 'key_contexts': ['emoji_list'], }, 'EXECUTE_SEARCH': { 'keys': ['enter'], 'help_text': 'Submit search and browse results', 'key_category': 'searching', + 'key_contexts': ['search_box'], }, 'CLEAR_SEARCH': { 'keys': ['esc'], 'help_text': 'Clear search in current panel', 'key_category': 'searching', + 'key_contexts': ['message', 'stream', 'topic', 'user'], + 'excluded_from_random_tips': True, + # TODO: condition check required }, 'TOGGLE_MUTE_STREAM': { 'keys': ['m'], 'help_text': 'Mute/unmute streams', 'key_category': 'stream_list', + 'key_contexts': ['stream'], }, 'THUMBS_UP': { 'keys': ['+'], 'help_text': 'Toggle thumbs-up reaction to the current message', 'key_category': 'msg_actions', + 'key_contexts': ['message'], }, 'TOGGLE_STAR_STATUS': { 'keys': ['ctrl s', '*'], 'help_text': 'Toggle star status of the current message', 'key_category': 'msg_actions', + 'key_contexts': ['message'], }, 'MSG_INFO': { 'keys': ['i'], 'help_text': 'Show/hide message information', 'key_category': 'msg_actions', + 'key_contexts': ['message', 'msg_info'], }, 'MSG_SENDER_INFO': { 'keys': ['u'], 'help_text': 'Show/hide message sender information', 'key_category': 'msg_actions', + 'key_contexts': ['msg_info'], }, 'EDIT_HISTORY': { 'keys': ['e'], 'help_text': 'Show/hide edit history (from message information)', - 'excluded_from_random_tips': True, 'key_category': 'msg_actions', + 'key_contexts': ['msg_info'], }, 'VIEW_IN_BROWSER': { 'keys': ['v'], 'help_text': 'View current message in browser (from message information)', - 'excluded_from_random_tips': True, 'key_category': 'msg_actions', + 'key_contexts': ['msg_info'], }, 'STREAM_INFO': { 'keys': ['i'], 'help_text': 'Show/hide stream information & modify settings', 'key_category': 'stream_list', + 'key_contexts': ['stream', 'stream_info'], }, 'STREAM_MEMBERS': { 'keys': ['m'], 'help_text': 'Show/hide stream members (from stream information)', - 'excluded_from_random_tips': True, 'key_category': 'stream_list', + 'key_contexts': ['stream_info'], }, 'COPY_STREAM_EMAIL': { 'keys': ['c'], 'help_text': 'Copy stream email to clipboard (from stream information)', - 'excluded_from_random_tips': True, 'key_category': 'stream_list', + 'key_contexts': ['stream_info'], }, 'REDRAW': { 'keys': ['ctrl l'], 'help_text': 'Redraw screen', 'key_category': 'general', + 'key_contexts': ['global'], }, 'QUIT': { 'keys': ['ctrl c'], 'help_text': 'Quit', 'key_category': 'general', + 'key_contexts': ['global'], }, 'USER_INFO': { 'keys': ['i'], 'help_text': 'Show/hide user information (from users list)', 'key_category': 'general', + 'key_contexts': ['user'], }, 'BEGINNING_OF_LINE': { 'keys': ['ctrl a', 'home'], 'help_text': 'Start of line', 'key_category': 'editor_navigation', + 'key_contexts': ['editor'], }, 'END_OF_LINE': { 'keys': ['ctrl e', 'end'], 'help_text': 'End of line', 'key_category': 'editor_navigation', + 'key_contexts': ['editor'], }, 'ONE_WORD_BACKWARD': { 'keys': ['meta b', 'shift left'], 'help_text': 'Start of current or previous word', 'key_category': 'editor_navigation', + 'key_contexts': ['editor'], }, 'ONE_WORD_FORWARD': { 'keys': ['meta f', 'shift right'], 'help_text': 'Start of next word', 'key_category': 'editor_navigation', + 'key_contexts': ['editor'], }, 'PREV_LINE': { 'keys': ['up', 'ctrl p'], 'help_text': 'Previous line', 'key_category': 'editor_navigation', + 'key_contexts': ['editor'], }, 'NEXT_LINE': { 'keys': ['down', 'ctrl n'], 'help_text': 'Next line', 'key_category': 'editor_navigation', + 'key_contexts': ['editor'], }, 'UNDO_LAST_ACTION': { 'keys': ['ctrl _'], 'help_text': 'Undo last action', 'key_category': 'editor_text_manipulation', + 'key_contexts': ['editor'], }, 'CLEAR_MESSAGE': { 'keys': ['ctrl l'], 'help_text': 'Clear text box', 'key_category': 'editor_text_manipulation', + 'key_contexts': ['editor'], }, 'CUT_TO_END_OF_LINE': { 'keys': ['ctrl k'], 'help_text': 'Cut forwards to the end of the line', 'key_category': 'editor_text_manipulation', + 'key_contexts': ['editor'], }, 'CUT_TO_START_OF_LINE': { 'keys': ['ctrl u'], 'help_text': 'Cut backwards to the start of the line', 'key_category': 'editor_text_manipulation', + 'key_contexts': ['editor'], }, 'CUT_TO_END_OF_WORD': { 'keys': ['meta d'], 'help_text': 'Cut forwards to the end of the current word', 'key_category': 'editor_text_manipulation', + 'key_contexts': ['editor'], }, 'CUT_TO_START_OF_WORD': { 'keys': ['ctrl w', 'meta backspace'], 'help_text': 'Cut backwards to the start of the current word', 'key_category': 'editor_text_manipulation', + 'key_contexts': ['editor'], }, 'CUT_WHOLE_LINE': { 'keys': ['meta x'], 'help_text': 'Cut the current line', 'key_category': 'editor_text_manipulation', + 'key_contexts': ['editor'], }, 'PASTE_LAST_CUT': { 'keys': ['ctrl y'], 'help_text': 'Paste last cut section', 'key_category': 'editor_text_manipulation', + 'key_contexts': ['editor'], }, 'DELETE_LAST_CHARACTER': { 'keys': ['ctrl h'], 'help_text': 'Delete previous character', 'key_category': 'editor_text_manipulation', + 'key_contexts': ['editor'], }, 'TRANSPOSE_CHARACTERS': { 'keys': ['ctrl t'], 'help_text': 'Swap with previous character', 'key_category': 'editor_text_manipulation', + 'key_contexts': ['editor'], }, 'NEW_LINE': { # urwid_readline's command @@ -427,26 +512,31 @@ class KeyBinding(TypedDict): 'keys': ['enter'], 'help_text': 'Insert new line', 'key_category': 'compose_box', + 'key_contexts': ['compose_box'], }, 'OPEN_EXTERNAL_EDITOR': { 'keys': ['ctrl o'], 'help_text': 'Open an external editor to edit the message content', 'key_category': 'compose_box', + 'key_contexts': ['compose_box'], }, 'FULL_RENDERED_MESSAGE': { 'keys': ['f'], 'help_text': 'Show/hide full rendered message (from message information)', 'key_category': 'msg_actions', + 'key_contexts': ['msg_info'], }, 'FULL_RAW_MESSAGE': { 'keys': ['r'], 'help_text': 'Show/hide full raw message (from message information)', 'key_category': 'msg_actions', + 'key_contexts': ['msg_info'], }, 'NEW_HINT': { 'keys': ['tab'], 'help_text': 'New footer hotkey hint', 'key_category': 'general', + 'key_contexts': ['general'], }, } # fmt: on @@ -464,6 +554,42 @@ class KeyBinding(TypedDict): "editor_text_manipulation": "Editor: Text Manipulation", } +HELP_CONTEXTS: Dict[str, str] = { + "global": "Global", + "general": "General", # not in an editor or a popup + "editor": "Editor", + "compose_box": "Compose box", + "stream": "Stream list", + "topic": "Topic list", + "user": "User list", + "message": "Message", + "stream_info": "Stream information", + "msg_info": "Message information", + "emoji_list": "Emoji list", + "about": "About information", + "popup": "Popup", + "button": "Button", + "search_box": "Search box", +} + +PARENT_CONTEXTS: Dict[str, List[str]] = { + "global": [], + "general": ["global"], + "editor": ["global"], + "compose_box": ["editor", "global"], + "stream": ["general", "global", "button"], + "topic": ["general", "global", "button"], + "user": ["general", "global", "button"], + "message": ["general", "global"], + "stream_info": ["global", "popup"], + "msg_info": ["global", "popup"], + "emoji_list": ["global", "popup"], + "about": ["global", "popup"], + "search_box": ["global", "editor"], + "popup": ["global"], + "button": ["global"], +} + ZT_TO_URWID_CMD_MAPPING = { "GO_UP": CURSOR_UP, "GO_DOWN": CURSOR_DOWN, @@ -549,15 +675,23 @@ def primary_display_key_for_command(command: str) -> str: return display_key_for_urwid_key(primary_key_for_command(command)) -def commands_for_random_tips() -> List[KeyBinding]: +def commands_for_random_tips(context: str = "") -> Tuple[List[KeyBinding], str]: """ - Return list of commands which may be displayed as a random tip + Return list of commands which may be displayed as a random tip, + and their associated user-facing context name """ - return [ + if not context or context not in HELP_CONTEXTS: + context = "global" + random_tips: List[KeyBinding] = [ key_binding for key_binding in KEY_BINDINGS.values() if not key_binding.get("excluded_from_random_tips", False) + and context in key_binding["key_contexts"] ] + if len(random_tips) == 0: + return commands_for_random_tips("global") + context_display_name = HELP_CONTEXTS.get(context, "Global") + return random_tips, context_display_name # Refer urwid/command_map.py diff --git a/zulipterminal/contexts.py b/zulipterminal/contexts.py new file mode 100644 index 0000000000..449ae23d35 --- /dev/null +++ b/zulipterminal/contexts.py @@ -0,0 +1,125 @@ +""" +Tracks the currently focused widget in the UI +""" +import re +from typing import Dict, List, Optional, Tuple, Union + +import urwid + +from zulipterminal.config.themes import ThemeSpec + + +# These include contexts that do not have any help hints as well, +# for the sake of completeness +AUTOHIDE_PREFIXES: Dict[Tuple[Union[int, str], ...], str] = { + (1, 0, 0): "menu_button", + (1, 0, 1, "body"): "stream_topic_button", + (1, 0, 1, "header"): "left_panel_search_box", + (1, "body"): "message_box", + (1, "header"): "message_search_box", + (1, "footer"): "compose_box", + (1, 1, "header"): "user_search_box", + (1, 1, "body"): "user_button", +} + +NON_AUTOHIDE_PREFIXES: Dict[Tuple[Union[int, str], ...], str] = { + (0, 0): "menu_button", + (0, 1, "body"): "stream_topic_button", + (0, 1, "header"): "left_panel_search_box", + (1, "body"): "message_box", + (1, "header"): "message_search_box", + (1, "footer"): "compose_box", + (2, "header"): "user_search_box", + (2, "body"): "user_button", +} + + +class FocusTrackingMainLoop(urwid.MainLoop): + def __init__( + self, + widget: urwid.Widget, + palette: ThemeSpec, + screen: Optional[urwid.BaseScreen], + ) -> None: + super().__init__(widget, palette, screen) + self.previous_focus_path = None + self.view = widget + + def process_input(self, input: List[str]) -> None: + super().process_input(input) + self.track_focus_change() + + def track_focus_change(self) -> None: + focus_path = self.widget.get_focus_path() + if focus_path != self.previous_focus_path: + # Update view's context irrespective of the focused widget + self.view.context = self.get_context_name(focus_path) + + self.previous_focus_path = focus_path + + def get_context_name(self, focus_path: Tuple[Union[int, str]]) -> str: + widget_in_focus = self.get_widget_in_focus(focus_path) + + if self.widget != self.view: + overlay_widget_to_context_map = { + "msg_info_popup": "msg_info", + "stream_info_popup": "stream_info", + "emoji_list_popup": "emoji_list", + "about_popup": "about", + } + return overlay_widget_to_context_map.get(widget_in_focus, "popup") + + widget_suffix_to_context_map = { + "user_button": "user", + "message_box": "message", + "stream_topic_button": ( + "topic" if self.widget.left_panel.is_in_topic_view else "stream" + ), + "topic": "topic", + "compose_box": "compose_box", + "search_box": "editor", + "button": "button", + } + for suffix, context in widget_suffix_to_context_map.items(): + if widget_in_focus.endswith(suffix): + return context + + return "general" + + def get_widget_in_focus(self, focus_path: Tuple[Union[int, str], ...]) -> str: + if isinstance(self.widget, urwid.Overlay): + # NoticeView and PopUpConfirmationView do not shift focus path on opening, + # until the user presses a recognized key that doesn't close the popup. + if len(focus_path) > 2: + # View -> AttrMap -> Frame -> LineBox -> the named Popup + popup_widget = ( + self.widget.contents[1][0].original_widget + ).body.original_widget + popup_widget_class = popup_widget.__class__.__name__ + + if popup_widget_class == "EmojiPickerView": + popup_widget_class = ( + "EmojiSearchView" + if focus_path[2] == "header" + else "EmojiListView" + ) + + # PascalCase to snake_case + return re.sub( + r"view", + r"popup", + re.sub(r"(? bool: def exit_popup(self) -> None: self.loop.widget = self.view - def show_help(self) -> None: - help_view = HelpView(self, f"Help Menu {SCROLL_PROMPT}") + def show_help(self, context: Optional[str] = None) -> None: + help_view = HelpView(self, f"Help Menu {SCROLL_PROMPT}", context) self.show_pop_up(help_view, "area:help") def show_markdown_help(self) -> None: @@ -445,7 +446,7 @@ def show_typing_notification(self) -> None: # Until conversation becomes "inactive" like when a `stop` event is sent while self.active_conversation_info: sender_name = self.active_conversation_info["sender_name"] - self.view.set_footer_text( + self.view.set_footer_text_for_event( [ ("footer_contrast", " " + sender_name + " "), " is typing" + next(dots), @@ -454,7 +455,7 @@ def show_typing_notification(self) -> None: time.sleep(0.45) self.is_typing_notification_in_progress = False - self.view.set_footer_text() + self.view.reset_footer_text() def report_error( self, @@ -464,7 +465,7 @@ def report_error( """ Helper to show an error message in footer """ - self.view.set_footer_text(text, "task:error", duration) + self.view.set_footer_text_for_event_duration(text, duration, "task:error") def report_success( self, @@ -474,7 +475,7 @@ def report_success( """ Helper to show a success message in footer """ - self.view.set_footer_text(text, "task:success", duration) + self.view.set_footer_text_for_event_duration(text, duration, "task:success") def report_warning( self, @@ -484,7 +485,7 @@ def report_warning( """ Helper to show a warning message in footer """ - self.view.set_footer_text(text, "task:warning", duration) + self.view.set_footer_text_for_event_duration(text, duration, "task:warning") def show_media_confirmation_popup( self, func: Any, tool: str, media_path: str diff --git a/zulipterminal/ui.py b/zulipterminal/ui.py index d0785bd928..03277b15de 100644 --- a/zulipterminal/ui.py +++ b/zulipterminal/ui.py @@ -5,7 +5,7 @@ import random import re import time -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple, Union import urwid @@ -49,6 +49,8 @@ def __init__(self, controller: Any) -> None: self.write_box = WriteBox(self) self.search_box = MessageSearchBox(self.controller) self.stream_topic_map: Dict[int, str] = {} + self._context: str = "global" + self._is_footer_event_running: bool = False self.message_view: Any = None self.displaying_selection_hint = False @@ -102,42 +104,62 @@ def right_column_view(self) -> Any: def get_random_help(self) -> List[Any]: # Get random allowed hotkey (ie. eligible for being displayed as a tip) - allowed_commands = commands_for_random_tips() + allowed_commands, tip_context = commands_for_random_tips(self.context) if not allowed_commands: - return ["Help(?): "] + return ["Help[?] "] random_command = random.choice(allowed_commands) random_command_display_keys = ", ".join( [display_key_for_urwid_key(key) for key in random_command["keys"]] ) return [ - "Help(?): ", + "Help[?] ", ("footer_contrast", f" {random_command_display_keys} "), - f" {random_command['help_text']}", + f" ({tip_context}) ", + ( + "footer_contrast", + f" {random_command['help_text']} ", + ), ] @asynch - def set_footer_text( - self, - text_list: Optional[List[Any]] = None, - style: str = "footer", - duration: Optional[float] = None, - ) -> None: + def set_footer_text(self, text: List[Any], style: str = "footer") -> None: # Avoid updating repeatedly (then pausing and showing default text) # This is simple, though doesn't avoid starting one thread for each call - if text_list == self._w.footer.text: + if text == self._w.footer.text: return - if text_list is None: - text = self.get_random_help() - else: - text = text_list self.frame.footer.set_text(text) self.frame.footer.set_attr_map({None: style}) self.controller.update_screen() - if duration is not None: - assert duration > 0 - time.sleep(duration) - self.set_footer_text() + + def reset_footer_text(self) -> None: + text = self.get_random_help() + self._is_footer_event_running = False + self.set_footer_text(text, "footer") + + def set_footer_text_for_event(self, text: List[Any], style: str = "footer") -> None: + self._is_footer_event_running = True + self.set_footer_text(text, style) + + def set_footer_text_for_event_duration( + self, text: List[Any], duration: float, style: str = "footer" + ) -> None: + self.set_footer_text_for_event(text, style) + time.sleep(duration) + self.reset_footer_text() + + def set_footer_text_on_context_change(self) -> None: + if self._is_footer_event_running: + return + self.reset_footer_text() + + def _update_context(self, context_value: str = "") -> None: + if self._context == context_value: + return + self._context = context_value + self.set_footer_text_on_context_change() + + context = property(lambda self: self._context, _update_context) @asynch def set_typeahead_footer( @@ -160,6 +182,9 @@ def footer_view(self) -> Any: text_header = self.get_random_help() return urwid.AttrWrap(urwid.Text(text_header), "footer") + def get_focus_path(self) -> Tuple[Union[int, str], ...]: + return self.frame.get_focus_path() + def main_window(self) -> Any: self.left_panel, self.left_tab = self.left_column_view() self.center_panel = self.middle_column_view() @@ -325,11 +350,14 @@ def keypress(self, size: urwid_Box, key: str) -> Optional[str]: # Show help menu self.controller.show_help() return key + elif is_command_key("CONTEXTUAL_HELP", key): + self.controller.show_help(self.context) + return key elif is_command_key("MARKDOWN_HELP", key): self.controller.show_markdown_help() return key elif is_command_key("NEW_HINT", key): - self.set_footer_text() + self.reset_footer_text() return key return super().keypress(size, key) @@ -337,7 +365,7 @@ def mouse_event( self, size: urwid_Box, event: str, button: int, col: int, row: int, focus: bool ) -> bool: if event == "mouse drag": - self.model.controller.view.set_footer_text( + self.model.controller.view.set_footer_text_for_event( [ "Try pressing ", ("footer_contrast", f" {MOUSE_SELECTION_KEY} "), @@ -347,7 +375,7 @@ def mouse_event( ) self.displaying_selection_hint = True elif event == "mouse release" and self.displaying_selection_hint: - self.model.controller.view.set_footer_text() + self.model.controller.view.reset_footer_text() self.displaying_selection_hint = False return super().mouse_event(size, event, button, col, row, focus) diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py index 1a479cadad..937ed735ae 100644 --- a/zulipterminal/ui_tools/boxes.py +++ b/zulipterminal/ui_tools/boxes.py @@ -727,7 +727,7 @@ def exit_compose_box(self) -> None: def _set_default_footer_after_autocomplete(self) -> None: self.is_in_typeahead_mode = False - self.view.set_footer_text() + self.view.reset_footer_text() def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if self.is_in_typeahead_mode and not ( diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 6d01a82566..a15a4c8fd5 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -14,6 +14,7 @@ from zulipterminal.config.keys import ( HELP_CATEGORIES, KEY_BINDINGS, + PARENT_CONTEXTS, display_key_for_urwid_key, display_keys_for_command, is_command_key, @@ -1238,13 +1239,21 @@ def _fetch_user_data( class HelpView(PopUpView): - def __init__(self, controller: Any, title: str) -> None: + def __init__( + self, controller: Any, title: str, context: Optional[str] = None + ) -> None: help_menu_content = [] + if context: + valid_contexts = PARENT_CONTEXTS[context] + [context] for category in HELP_CATEGORIES: keys_in_category = ( binding for binding in KEY_BINDINGS.values() if binding["key_category"] == category + and ( + not context + or bool(set(binding["key_contexts"]) & set(valid_contexts)) + ) ) key_bindings = [ ( @@ -1254,6 +1263,8 @@ def __init__(self, controller: Any, title: str) -> None: for binding in keys_in_category ] + if not key_bindings: + continue help_menu_content.append((HELP_CATEGORIES[category], key_bindings)) popup_width, column_widths = self.calculate_table_widths(