Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix command palette key #4890

Merged
merged 8 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 47 additions & 27 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down
17 changes: 17 additions & 0 deletions src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])."""
Expand Down Expand Up @@ -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(
Expand Down
28 changes: 6 additions & 22 deletions src/textual/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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


Expand Down
24 changes: 3 additions & 21 deletions src/textual/widgets/_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,6 @@ class FooterKey(Widget):
}
"""

upper_case_keys = reactive(False)
ctrl_to_caret = reactive(True)
compact = reactive(True)

def __init__(
Expand Down Expand Up @@ -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(
(
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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 (
Expand All @@ -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",
Expand Down
26 changes: 2 additions & 24 deletions src/textual/widgets/_key_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

Expand Down Expand Up @@ -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),
)

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading