Skip to content

Commit

Permalink
Merge pull request #4876 from Textualize/key-panel
Browse files Browse the repository at this point in the history
Key panel widget
  • Loading branch information
willmcgugan authored Aug 20, 2024
2 parents 8c01ef7 + faad0f5 commit 8a1c603
Show file tree
Hide file tree
Showing 32 changed files with 937 additions and 364 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `tooltip` to Binding https://github.com/Textualize/textual/pull/4859
- Added a link to the command palette to the Footer (set `show_command_palette=False` to disable) https://github.com/Textualize/textual/pull/4867
- Added `TOOLTIP_DELAY` to App to customize time until a tooltip is displayed
- 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

### 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

### Fixed

Expand Down
12 changes: 9 additions & 3 deletions examples/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from sys import argv

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.reactive import var
from textual.widgets import Footer, MarkdownViewer

Expand All @@ -12,9 +13,14 @@ class MarkdownApp(App):
"""A simple Markdown viewer application."""

BINDINGS = [
("t", "toggle_table_of_contents", "TOC"),
("b", "back", "Back"),
("f", "forward", "Forward"),
Binding(
"t",
"toggle_table_of_contents",
"TOC",
tooltip="Toggle the Table of Contents Panel",
),
Binding("b", "back", "Back", tooltip="Navigate back"),
Binding("f", "forward", "Forward", tooltip="Navigate forward"),
]

path = var(Path(__file__).parent / "demo.md")
Expand Down
102 changes: 83 additions & 19 deletions src/textual/_arrange.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def arrange(
placements: list[WidgetPlacement] = []
scroll_spacing = Spacing()
get_dock = attrgetter("styles.dock")
get_split = attrgetter("styles.split")
styles = widget.styles

# Widgets which will be displayed
Expand All @@ -56,39 +57,49 @@ def arrange(
# Widgets organized into layers
dock_layers = _build_dock_layers(display_widgets)

layer_region = size.region
for widgets in dock_layers.values():
region = layer_region
# Partition widgets in to split widgets and non-split widgets
non_split_widgets, split_widgets = partition(get_split, widgets)
if split_widgets:
_split_placements, dock_region = _arrange_split_widgets(
split_widgets, size, viewport
)
placements.extend(_split_placements)
else:
dock_region = size.region

split_spacing = size.region.get_spacing_between(dock_region)

# Partition widgets into "layout" widgets (those that appears in the normal 'flow' of the
# document), and "dock" widgets which are positioned relative to an edge
layout_widgets, dock_widgets = partition(get_dock, widgets)
layout_widgets, dock_widgets = partition(get_dock, non_split_widgets)

# Arrange docked widgets
_dock_placements, dock_spacing = _arrange_dock_widgets(
dock_widgets, size, viewport
)
placements.extend(_dock_placements)
if dock_widgets:
_dock_placements, dock_spacing = _arrange_dock_widgets(
dock_widgets, dock_region, viewport
)
placements.extend(_dock_placements)
dock_region = dock_region.shrink(dock_spacing)
else:
dock_spacing = Spacing()

# Reduce the region to compensate for docked widgets
region = region.shrink(dock_spacing)
dock_spacing += split_spacing

if layout_widgets:
# Arrange layout widgets (i.e. not docked)
layout_placements = widget._layout.arrange(
widget,
layout_widgets,
region.size,
dock_region.size,
)

scroll_spacing = scroll_spacing.grow_maximum(dock_spacing)

placement_offset = region.offset
placement_offset = dock_region.offset
# Perform any alignment of the widgets.
if styles.align_horizontal != "left" or styles.align_vertical != "top":
bounding_region = WidgetPlacement.get_bounds(layout_placements)
placement_offset += styles._align_size(
bounding_region.size, region.size
bounding_region.size, dock_region.size
).clamped

if placement_offset:
Expand All @@ -103,20 +114,22 @@ def arrange(


def _arrange_dock_widgets(
dock_widgets: Sequence[Widget], size: Size, viewport: Size
dock_widgets: Sequence[Widget], region: Region, viewport: Size
) -> tuple[list[WidgetPlacement], Spacing]:
"""Arrange widgets which are *docked*.
Args:
dock_widgets: Widgets with a non-empty dock.
size: Size of the container.
region: Region to dock within.
viewport: Size of the viewport.
Returns:
A tuple of widget placements, and additional spacing around them
A tuple of widget placements, and additional spacing around them.
"""
_WidgetPlacement = WidgetPlacement
top_z = TOP_Z
region_offset = region.offset
size = region.size
width, height = size
null_spacing = Spacing()

Expand All @@ -132,7 +145,6 @@ def _arrange_dock_widgets(
size, viewport, Fraction(size.width), Fraction(size.height)
)
widget_width_fraction, widget_height_fraction, margin = box_model

widget_width = int(widget_width_fraction) + margin.width
widget_height = int(widget_height_fraction) + margin.height

Expand All @@ -157,7 +169,59 @@ def _arrange_dock_widgets(
)
dock_region = dock_region.shrink(margin).translate(align_offset)
append_placement(
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
_WidgetPlacement(
dock_region.translate(region_offset),
null_spacing,
dock_widget,
top_z,
True,
)
)
dock_spacing = Spacing(top, right, bottom, left)
return (placements, dock_spacing)


def _arrange_split_widgets(
split_widgets: Sequence[Widget], size: Size, viewport: Size
) -> tuple[list[WidgetPlacement], Region]:
"""Arrange split widgets.
Split widgets are "docked" but also reduce the area available for regular widgets.
Args:
split_widgets: Widgets to arrange.
size: Available area to arrange.
viewport: Viewport (size of terminal).
Returns:
A tuple of widget placements, and the remaining view area.
"""
_WidgetPlacement = WidgetPlacement
placements: list[WidgetPlacement] = []
append_placement = placements.append
view_region = size.region
null_spacing = Spacing()

for split_widget in split_widgets:
split = split_widget.styles.split
box_model = split_widget._get_box_model(
size, viewport, Fraction(size.width), Fraction(size.height)
)
widget_width_fraction, widget_height_fraction, margin = box_model
if split == "bottom":
widget_height = int(widget_height_fraction) + margin.height
view_region, split_region = view_region.split_horizontal(-widget_height)
elif split == "top":
widget_height = int(widget_height_fraction) + margin.height
split_region, view_region = view_region.split_horizontal(widget_height)
elif split == "left":
widget_width = int(widget_width_fraction) + margin.width
split_region, view_region = view_region.split_vertical(widget_width)
elif split == "right":
widget_width = int(widget_width_fraction) + margin.width
view_region, split_region = view_region.split_vertical(-widget_width)
append_placement(
_WidgetPlacement(split_region, null_spacing, split_widget, 1, True)
)

return placements, view_region
6 changes: 4 additions & 2 deletions src/textual/_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,10 @@ def get_content_width(self, widget: Widget, container: Size, viewport: Size) ->
if not widget._nodes:
width = 0
else:
arrangement = widget._arrange(Size(0, 0))
return arrangement.total_region.right
arrangement = widget._arrange(
Size(0 if widget.shrink else container.width, 0)
)
width = arrangement.total_region.right
return width

def get_content_height(
Expand Down
23 changes: 21 additions & 2 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1319,7 +1319,9 @@ def bind(
keys, action, description, show=show, key_display=key_display
)

def get_key_display(self, key: str) -> str:
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
Expand All @@ -1329,11 +1331,15 @@ def get_key_display(self, key: str) -> str:
Args:
key: The binding key string.
upper_case_keys: Upper case printable keys.
ctrl_to_caret: Replace `ctrl+` with `^`.
Returns:
The display string for the input key.
"""
return _get_key_display(key)
return _get_key_display(
key, upper_case_keys=upper_case_keys, ctrl_to_caret=ctrl_to_caret
)

async def _press_keys(self, keys: Iterable[str]) -> None:
"""A task to send key events."""
Expand Down Expand Up @@ -3498,6 +3504,19 @@ def action_focus_previous(self) -> None:
"""An [action](/guide/actions) to focus the previous widget."""
self.screen.focus_previous()

def action_hide_keys(self) -> None:
"""Hide the keys panel (if present)."""
self.screen.query("KeyPanel").remove()

def action_show_keys(self) -> None:
"""Show the keys panel."""
from .widgets import KeyPanel

try:
self.query_one(KeyPanel)
except NoMatches:
self.mount(KeyPanel())

def _on_terminal_supports_synchronized_output(
self, message: messages.TerminalSupportsSynchronizedOutput
) -> None:
Expand Down
22 changes: 10 additions & 12 deletions src/textual/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,6 @@ class CommandPalette(SystemModalScreen):
"""

DEFAULT_CSS = """
CommandPalette:inline {
/* If the command palette is invoked in inline mode, we may need additional lines. */
Expand Down Expand Up @@ -627,7 +626,7 @@ def compose(self) -> ComposeResult:
Returns:
The content of the screen.
"""
with Vertical():
with Vertical(id="--container"):
with Horizontal(id="--input"):
yield SearchIcon()
yield CommandInput(placeholder="Search for commands…")
Expand Down Expand Up @@ -1068,10 +1067,15 @@ def _select_or_command(
if event is not None:
event.stop()
if self._list_visible:
command_list = self.query_one(CommandList)
# ...so if nothing in the list is highlighted yet...
if self.query_one(CommandList).highlighted is None:
if command_list.highlighted is None:
# ...cause the first completion to be highlighted.
self._action_cursor_down()
# If there is one option, assume the user wants to select it
if command_list.option_count == 1:
# Call after a short delay to provide a little visual feedback
self._action_command_list("select")
else:
# The list is visible, something is highlighted, the user
# made a selection "gesture"; let's go select it!
Expand All @@ -1095,15 +1099,9 @@ def _stop_event_leak(self, event: OptionList.OptionHighlighted) -> None:

def _action_escape(self) -> None:
"""Handle a request to escape out of the command palette."""
input = self.query_one(CommandInput)
# Hide the options if there are result and there is input
if self._list_visible and (self._hit_count and input.value):
self._list_visible = False
# Otherwise dismiss modal
else:
self._cancel_gather_commands()
self.app.post_message(CommandPalette.Closed(option_selected=False))
self.dismiss()
self._cancel_gather_commands()
self.app.post_message(CommandPalette.Closed(option_selected=False))
self.dismiss()

def _action_command_list(self, action: str) -> None:
"""Pass an action on to the [`CommandList`][textual.command.CommandList].
Expand Down
4 changes: 2 additions & 2 deletions src/textual/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ def _get_textual_animations() -> AnimationLevel:
COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto")
"""Force color system override."""

TEXTUAL_ANIMATIONS: AnimationLevel = _get_textual_animations()
TEXTUAL_ANIMATIONS: Final[AnimationLevel] = _get_textual_animations()
"""Determines whether animations run or not."""

ESCAPE_DELAY: float = _get_environ_int("ESCDELAY", 100) / 1000.0
ESCAPE_DELAY: Final[float] = _get_environ_int("ESCDELAY", 100) / 1000.0
"""The delay (in seconds) before reporting an escape key (not used if the extend key protocol is available)."""
33 changes: 33 additions & 0 deletions src/textual/css/_help_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,39 @@ def dock_property_help_text(property_name: str, context: StylingContext) -> Help
)


def split_property_help_text(property_name: str, context: StylingContext) -> HelpText:
"""Help text to show when the user supplies an invalid value for split.
Args:
property_name: The name of the property.
context: The context the property is being used in.
Returns:
Renderable for displaying the help text for this property.
"""
property_name = _contextualize_property_name(property_name, context)
return HelpText(
summary=f"Invalid value for [i]{property_name}[/] property",
bullets=[
Bullet("The value must be one of 'top', 'right', 'bottom' or 'left'"),
*ContextSpecificBullets(
inline=[
Bullet(
"The 'split' splits the container and aligns the widget to the given edge.",
examples=[Example('header.styles.split = "top"')],
)
],
css=[
Bullet(
"The 'split' splits the container and aligns the widget to the given edge.",
examples=[Example("split: top")],
)
],
).get_by_context(context),
],
)


def fractional_property_help_text(
property_name: str, context: StylingContext
) -> HelpText:
Expand Down
Loading

0 comments on commit 8a1c603

Please sign in to comment.