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(