diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7448d1f40..ae6ae055ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,11 +16,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added "Show keys" option to system commands to show a summary of key bindings. https://github.com/Textualize/textual/pull/4876
- Added "split" CSS style, currently undocumented, and may change. https://github.com/Textualize/textual/pull/4876
- Added `Region.get_spacing_between` https://github.com/Textualize/textual/pull/4876
+- Added `App.COMMAND_PALETTE_KEY` to change default command palette key binding https://github.com/Textualize/textual/pull/4867
+- Added `App.get_key_display` https://github.com/Textualize/textual/pull/4890
### Changed
- Removed caps_lock and num_lock modifiers https://github.com/Textualize/textual/pull/4861
- Keys such as escape and space are now displayed in lower case in footer https://github.com/Textualize/textual/pull/4876
+- Changed default command palette binding to `ctrl+p` https://github.com/Textualize/textual/pull/4867
+- Removed `ctrl_to_caret` and `upper_case_keys` from Footer. These can be implemented in `App.get_key_display`.
### Fixed
diff --git a/src/textual/app.py b/src/textual/app.py
index aad8e6c836..cd8efc47e1 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -96,8 +96,8 @@
from .keys import (
REPLACED_KEYS,
_character_to_key,
- _get_key_display,
_get_unicode_name_from_key,
+ format_key,
)
from .messages import CallbackType, Prune
from .notifications import Notification, Notifications, Notify, SeverityLevel
@@ -367,18 +367,13 @@ class MyApp(App[None]):
"""
COMMAND_PALETTE_BINDING: ClassVar[str] = "ctrl+p"
- """The key that launches the command palette (if enabled)."""
+ """The key that launches the command palette (if enabled by [`App.ENABLE_COMMAND_PALETTE`][textual.app.App.ENABLE_COMMAND_PALETTE])."""
+
+ COMMAND_PALETTE_DISPLAY: ClassVar[str | None] = None
+ """How the command palette key should be displayed in the footer (or `None` for default)."""
BINDINGS: ClassVar[list[BindingType]] = [
- Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
- Binding(
- COMMAND_PALETTE_BINDING,
- "command_palette",
- "palette",
- show=False,
- priority=True,
- tooltip="Open command palette",
- ),
+ Binding("ctrl+c", "quit", "Quit", show=False, priority=True)
]
"""The default key bindings."""
@@ -650,6 +645,23 @@ def __init__(
# Size of previous inline update
self._previous_inline_height: int | None = None
+ if self.ENABLE_COMMAND_PALETTE:
+ for _key, binding in self._bindings:
+ if binding.action in {"command_palette", "app.command_palette"}:
+ break
+ else:
+ self._bindings._add_binding(
+ Binding(
+ self.COMMAND_PALETTE_BINDING,
+ "command_palette",
+ "palette",
+ show=False,
+ key_display=self.COMMAND_PALETTE_DISPLAY,
+ priority=True,
+ tooltip="Open command palette",
+ )
+ )
+
def validate_title(self, title: Any) -> str:
"""Make sure the title is set to a string."""
return str(title)
@@ -1319,27 +1331,35 @@ def bind(
keys, action, description, show=show, key_display=key_display
)
- def get_key_display(
- self, key: str, upper_case_keys: bool = False, ctrl_to_caret: bool = True
- ) -> str:
- """For a given key, return how it should be displayed in an app
- (e.g. in the Footer widget).
- By key, we refer to the string used in the "key" argument for
- a Binding instance. By overriding this method, you can ensure that
- keys are displayed consistently throughout your app, without
- needing to add a key_display to every binding.
+ def get_key_display(self, binding: Binding) -> str:
+ """Format a bound key for display in footer / key panel etc.
+
+ !!! note
+ You can implement this in a subclass if you want to change how keys are displayed in your app.
Args:
- key: The binding key string.
- upper_case_keys: Upper case printable keys.
- ctrl_to_caret: Replace `ctrl+` with `^`.
+ binding: A Binding.
Returns:
- The display string for the input key.
+ A string used to represent the key.
"""
- return _get_key_display(
- key, upper_case_keys=upper_case_keys, ctrl_to_caret=ctrl_to_caret
- )
+ # Dev has overridden the key display, so use that
+ if binding.key_display:
+ return binding.key_display
+
+ # Extract modifiers
+ modifiers, key = binding.parse_key()
+
+ # Format the key (replace unicode names with character)
+ key = format_key(key)
+
+ # Convert ctrl modifier to caret
+ if "ctrl" in modifiers:
+ modifiers.pop(modifiers.index("ctrl"))
+ key = f"^{key}"
+ # Join everything with +
+ key_tokens = modifiers + [key]
+ return "+".join(key_tokens)
async def _press_keys(self, keys: Iterable[str]) -> None:
"""A task to send key events."""
diff --git a/src/textual/binding.py b/src/textual/binding.py
index 3b0825d8a2..5fe58cb487 100644
--- a/src/textual/binding.py
+++ b/src/textual/binding.py
@@ -53,6 +53,15 @@ class Binding:
tooltip: str = ""
"""Optional tooltip to show in footer."""
+ def parse_key(self) -> tuple[list[str], str]:
+ """Parse a key in to a list of modifiers, and the actual key.
+
+ Returns:
+ A tuple of (MODIFIER LIST, KEY).
+ """
+ *modifiers, key = self.key.split("+")
+ return modifiers, key
+
class ActiveBinding(NamedTuple):
"""Information about an active binding (returned from [active_bindings][textual.screen.Screen.active_bindings])."""
@@ -123,6 +132,14 @@ def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
for binding in make_bindings(bindings or {}):
self.key_to_bindings.setdefault(binding.key, []).append(binding)
+ def _add_binding(self, binding: Binding) -> None:
+ """Add a new binding.
+
+ Args:
+ binding: New Binding to add.
+ """
+ self.key_to_bindings.setdefault(binding.key, []).append(binding)
+
def __iter__(self) -> Iterator[tuple[str, Binding]]:
"""Iterating produces a sequence of (KEY, BINDING) tuples."""
return iter(
diff --git a/src/textual/keys.py b/src/textual/keys.py
index 72e4cdc395..0e47d15320 100644
--- a/src/textual/keys.py
+++ b/src/textual/keys.py
@@ -281,24 +281,10 @@ def _get_key_aliases(key: str) -> list[str]:
return [key] + KEY_ALIASES.get(key, [])
-def _get_key_display(
- key: str,
- upper_case_keys: bool = False,
- ctrl_to_caret: bool = True,
-) -> str:
+def format_key(key: str) -> str:
"""Given a key (i.e. the `key` string argument to Binding __init__),
return the value that should be displayed in the app when referring
to this key (e.g. in the Footer widget)."""
- if "+" in key:
- key_components = key.split("+")
- caret = False
- if ctrl_to_caret and "ctrl" in key_components:
- key_components.remove("ctrl")
- caret = True
- key_display = ("^" if caret else "") + "+".join(
- [_get_key_display(key) for key in key_components]
- )
- return key_display
display_alias = KEY_DISPLAY_ALIASES.get(key)
if display_alias:
@@ -307,14 +293,12 @@ def _get_key_display(
original_key = REPLACED_KEYS.get(key, key)
tentative_unicode_name = _get_unicode_name_from_key(original_key)
try:
- unicode_character = unicodedata.lookup(tentative_unicode_name)
+ unicode_name = unicodedata.lookup(tentative_unicode_name)
except KeyError:
- return tentative_unicode_name
-
- # Check if printable. `delete` for example maps to a control sequence
- # which we don't want to write to the terminal.
- if unicode_character.isprintable():
- return unicode_character.upper() if upper_case_keys else unicode_character
+ pass
+ else:
+ if unicode_name.isprintable():
+ return unicode_name
return tentative_unicode_name
diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py
index 3db09a2938..a6eedcb3b6 100644
--- a/src/textual/widgets/_footer.py
+++ b/src/textual/widgets/_footer.py
@@ -73,8 +73,6 @@ class FooterKey(Widget):
}
"""
- upper_case_keys = reactive(False)
- ctrl_to_caret = reactive(True)
compact = reactive(True)
def __init__(
@@ -106,10 +104,6 @@ def render(self) -> Text:
description_padding = self.get_component_styles(
"footer-key--description"
).padding
- if self.upper_case_keys:
- key_display = key_display.upper()
- if self.ctrl_to_caret and key_display.lower().startswith("ctrl+"):
- key_display = "^" + key_display.split("+", 1)[1]
description = self.description
label_text = Text.assemble(
(
@@ -158,10 +152,6 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False):
}
"""
- upper_case_keys = reactive(False)
- """Upper case key display."""
- ctrl_to_caret = reactive(True)
- """Convert 'ctrl+' prefix to '^'."""
compact = reactive(False)
"""Display in compact style."""
_bindings_ready = reactive(False, repaint=False)
@@ -176,8 +166,6 @@ def __init__(
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
- upper_case_keys: bool = False,
- ctrl_to_caret: bool = True,
show_command_palette: bool = True,
) -> None:
"""A footer to show key bindings.
@@ -199,8 +187,6 @@ def __init__(
classes=classes,
disabled=disabled,
)
- self.set_reactive(Footer.upper_case_keys, upper_case_keys)
- self.set_reactive(Footer.ctrl_to_caret, ctrl_to_caret)
self.set_reactive(Footer.show_command_palette, show_command_palette)
def compose(self) -> ComposeResult:
@@ -221,16 +207,12 @@ def compose(self) -> ComposeResult:
binding, enabled, tooltip = multi_bindings[0]
yield FooterKey(
binding.key,
- binding.key_display or self.app.get_key_display(binding.key),
+ self.app.get_key_display(binding),
binding.description,
binding.action,
disabled=not enabled,
tooltip=tooltip,
- ).data_bind(
- Footer.upper_case_keys,
- Footer.ctrl_to_caret,
- Footer.compact,
- )
+ ).data_bind(Footer.compact)
if self.show_command_palette and self.app.ENABLE_COMMAND_PALETTE:
for key, binding in self.app._bindings:
if binding.action in (
@@ -239,7 +221,7 @@ def compose(self) -> ComposeResult:
):
yield FooterKey(
key,
- binding.key_display or binding.key,
+ self.app.get_key_display(binding),
binding.description,
binding.action,
classes="-command-palette",
diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py
index 7f4441322c..d60724e22e 100644
--- a/src/textual/widgets/_key_panel.py
+++ b/src/textual/widgets/_key_panel.py
@@ -9,7 +9,6 @@
from ..app import ComposeResult
from ..binding import Binding
from ..containers import VerticalScroll
-from ..reactive import reactive
from ..widgets import Static
if TYPE_CHECKING:
@@ -28,11 +27,6 @@ class BindingsTable(Static):
}
"""
- upper_case_keys = reactive(False)
- """Upper case key display."""
- ctrl_to_caret = reactive(True)
- """Convert 'ctrl+' prefix to '^'."""
-
def render_bindings_table(self) -> Table:
"""Render a table with all the key bindings.
@@ -66,15 +60,7 @@ def render_description(binding: Binding) -> Text:
for multi_bindings in action_to_bindings.values():
binding, enabled, tooltip = multi_bindings[0]
table.add_row(
- Text(
- binding.key_display
- or self.app.get_key_display(
- binding.key,
- upper_case_keys=self.upper_case_keys,
- ctrl_to_caret=self.ctrl_to_caret,
- ),
- style=key_style,
- ),
+ Text(self.app.get_key_display(binding), style=key_style),
render_description(binding),
)
@@ -113,18 +99,10 @@ class KeyPanel(VerticalScroll, can_focus=False):
}
"""
- upper_case_keys = reactive(False)
- """Upper case key display."""
- ctrl_to_caret = reactive(True)
- """Convert 'ctrl+' prefix to '^'."""
-
DEFAULT_CLASSES = "-textual-system"
def compose(self) -> ComposeResult:
- yield BindingsTable(shrink=True, expand=False).data_bind(
- KeyPanel.upper_case_keys,
- KeyPanel.ctrl_to_caret,
- )
+ yield BindingsTable(shrink=True, expand=False)
async def on_mount(self) -> None:
async def bindings_changed(screen: Screen) -> None:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_key_change.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_key_change.svg
new file mode 100644
index 0000000000..9da9db84a4
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_key_change.svg
@@ -0,0 +1,154 @@
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_classic_styling.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_classic_styling.svg
index ee0ad99610..1c88ee485d 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_classic_styling.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_footer_classic_styling.svg
@@ -19,135 +19,135 @@
font-weight: 700;
}
- .terminal-407601934-matrix {
+ .terminal-2379907918-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-407601934-title {
+ .terminal-2379907918-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-407601934-r1 { fill: #e1e1e1 }
-.terminal-407601934-r2 { fill: #c5c8c6 }
-.terminal-407601934-r3 { fill: #dde8f3;font-weight: bold }
-.terminal-407601934-r4 { fill: #ddedf9 }
-.terminal-407601934-r5 { fill: #308fd9 }
+ .terminal-2379907918-r1 { fill: #e1e1e1 }
+.terminal-2379907918-r2 { fill: #c5c8c6 }
+.terminal-2379907918-r3 { fill: #dde8f3;font-weight: bold }
+.terminal-2379907918-r4 { fill: #ddedf9 }
+.terminal-2379907918-r5 { fill: #308fd9 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- ClassicFooterStylingApp
+ ClassicFooterStylingApp
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ^T Toggle Dark mode ^Q Quit ▏^p palette
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ^t Toggle Dark mode ^q Quit ▏^p palette
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_key_display.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_key_display.svg
index 98d70360f1..160601a0a0 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_key_display.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_key_display.svg
@@ -19,136 +19,136 @@
font-weight: 700;
}
- .terminal-121821627-matrix {
+ .terminal-2027215108-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-121821627-title {
+ .terminal-2027215108-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-121821627-r1 { fill: #e1e1e1 }
-.terminal-121821627-r2 { fill: #c5c8c6 }
-.terminal-121821627-r3 { fill: #fea62b;font-weight: bold }
-.terminal-121821627-r4 { fill: #a7a9ab }
-.terminal-121821627-r5 { fill: #e2e3e3 }
-.terminal-121821627-r6 { fill: #4c5055 }
+ .terminal-2027215108-r1 { fill: #e1e1e1 }
+.terminal-2027215108-r2 { fill: #c5c8c6 }
+.terminal-2027215108-r3 { fill: #fea62b;font-weight: bold }
+.terminal-2027215108-r4 { fill: #a7a9ab }
+.terminal-2027215108-r5 { fill: #e2e3e3 }
+.terminal-2027215108-r6 { fill: #4c5055 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- KeyDisplayApp
+ KeyDisplayApp
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ? Question ^q Quit app Escape! Escape a Letter A ▏^p palette
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ? Question ^q Quit app esc Escape a Letter A ▏^p palette
diff --git a/tests/snapshot_tests/snapshot_apps/command_palette_key.py b/tests/snapshot_tests/snapshot_apps/command_palette_key.py
new file mode 100644
index 0000000000..179b90156a
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/command_palette_key.py
@@ -0,0 +1,15 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Footer
+
+
+class NewPaletteBindingApp(App):
+ COMMAND_PALETTE_BINDING = "ctrl+backslash"
+ COMMAND_PALETTE_DISPLAY = "ctrl+\\"
+
+ def compose(self) -> ComposeResult:
+ yield Footer()
+
+
+if __name__ == "__main__":
+ app = NewPaletteBindingApp()
+ app.run()
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 80eb674a2d..82c131c954 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -1440,6 +1440,11 @@ async def run_before(pilot: Pilot):
)
+def test_command_palette_key_change(snap_compare):
+ """Regression test for https://github.com/Textualize/textual/issues/4887"""
+ assert snap_compare(SNAPSHOT_APPS_DIR / "command_palette_key.py")
+
+
def test_split(snap_compare):
"""Test split rule."""
assert snap_compare(SNAPSHOT_APPS_DIR / "split.py", terminal_size=(100, 30))
diff --git a/tests/test_keys.py b/tests/test_keys.py
index 5d9f8be194..66b279a8fb 100644
--- a/tests/test_keys.py
+++ b/tests/test_keys.py
@@ -1,7 +1,8 @@
import pytest
from textual.app import App
-from textual.keys import _character_to_key, _get_key_display
+from textual.binding import Binding
+from textual.keys import _character_to_key, format_key
@pytest.mark.parametrize(
@@ -50,22 +51,19 @@ def action_increment(self) -> None:
assert counter == 3
-def test_get_key_display():
- assert _get_key_display("minus") == "-"
-
+def test_format_key():
+ assert format_key("minus") == "-"
-def test_get_key_display_when_used_in_conjunction():
- """Test a key display is the same if used in conjunction with another key.
- For example, "ctrl+right_square_bracket" should display the bracket as "]",
- the same as it would without the ctrl modifier.
- Regression test for #3035 https://github.com/Textualize/textual/issues/3035
- """
-
- right_square_bracket = _get_key_display("right_square_bracket")
- ctrl_right_square_bracket = _get_key_display("ctrl+right_square_bracket")
- assert ctrl_right_square_bracket == f"^{right_square_bracket}"
+def test_get_key_display():
+ app = App()
- left = _get_key_display("left")
- ctrl_left = _get_key_display("ctrl+left")
- assert ctrl_left == f"^{left}"
+ assert app.get_key_display(Binding("p", "", "")) == "p"
+ assert app.get_key_display(Binding("ctrl+p", "", "")) == "^p"
+ assert app.get_key_display(Binding("right_square_bracket", "", "")) == "]"
+ assert app.get_key_display(Binding("ctrl+right_square_bracket", "", "")) == "^]"
+ assert (
+ app.get_key_display(Binding("shift+ctrl+right_square_bracket", "", ""))
+ == "shift+^]"
+ )
+ assert app.get_key_display(Binding("delete", "", "")) == "delete"