diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4ffcc3ba3..e7448d1f40 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/examples/markdown.py b/examples/markdown.py
index 2cf43e4399..eb8bd16391 100644
--- a/examples/markdown.py
+++ b/examples/markdown.py
@@ -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
@@ -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")
diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py
index 97d8abbe4a..52b1ea1c85 100644
--- a/src/textual/_arrange.py
+++ b/src/textual/_arrange.py
@@ -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
@@ -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:
@@ -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()
@@ -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
@@ -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
diff --git a/src/textual/_layout.py b/src/textual/_layout.py
index 312f8c9fe5..a387603634 100644
--- a/src/textual/_layout.py
+++ b/src/textual/_layout.py
@@ -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(
diff --git a/src/textual/app.py b/src/textual/app.py
index 6bb0fbe827..aa8595bb59 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -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
@@ -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."""
@@ -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:
diff --git a/src/textual/command.py b/src/textual/command.py
index 44a03ca72c..2228a1d589 100644
--- a/src/textual/command.py
+++ b/src/textual/command.py
@@ -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. */
@@ -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…")
@@ -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!
@@ -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].
diff --git a/src/textual/constants.py b/src/textual/constants.py
index 3dbbc17988..8cde416796 100644
--- a/src/textual/constants.py
+++ b/src/textual/constants.py
@@ -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)."""
diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py
index f2ca1564c8..da811bad73 100644
--- a/src/textual/css/_help_text.py
+++ b/src/textual/css/_help_text.py
@@ -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:
diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py
index f35dd74de1..feaaf07add 100644
--- a/src/textual/css/_style_properties.py
+++ b/src/textual/css/_style_properties.py
@@ -599,6 +599,37 @@ def __set__(self, obj: StylesBase, dock_name: str | None):
obj.refresh(layout=True)
+class SplitProperty:
+ """Descriptor for getting and setting the split property. The split property
+ allows you to specify which edge you want to split.
+ """
+
+ def __get__(
+ self, obj: StylesBase, objtype: type[StylesBase] | None = None
+ ) -> DockEdge:
+ """Get the Dock property.
+
+ Args:
+ obj: The ``Styles`` object.
+ objtype: The ``Styles`` class.
+
+ Returns:
+ The dock name as a string, or "" if the rule is not set.
+ """
+ return obj.get_rule("split", "") # type: ignore[return-value]
+
+ def __set__(self, obj: StylesBase, dock_name: str | None):
+ """Set the Dock property.
+
+ Args:
+ obj: The ``Styles`` object.
+ dock_name: The name of the dock to attach this widget to.
+ """
+ _rich_traceback_omit = True
+ if obj.set_rule("split", dock_name):
+ obj.refresh(layout=True)
+
+
class LayoutProperty:
"""Descriptor for getting and setting layout."""
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index 622312a861..3107375a83 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -31,6 +31,7 @@
scrollbar_size_single_axis_help_text,
spacing_invalid_value_help_text,
spacing_wrong_number_of_values_help_text,
+ split_property_help_text,
string_enum_help_text,
style_flags_property_help_text,
table_rows_or_columns_help_text,
@@ -748,6 +749,20 @@ def process_dock(self, name: str, tokens: list[Token]) -> None:
dock = tokens[0].value
self.styles._rules["dock"] = dock
+ def process_split(self, name: str, tokens: list[Token]) -> None:
+ if not tokens:
+ return
+
+ if len(tokens) > 1 or tokens[0].value not in VALID_EDGE:
+ self.error(
+ name,
+ tokens[0],
+ split_property_help_text(name, context="css"),
+ )
+
+ dock = tokens[0].value
+ self.styles._rules["split"] = dock
+
def process_layer(self, name: str, tokens: list[Token]) -> None:
if len(tokens) > 1:
self.error(name, tokens[1], "unexpected tokens in dock-edge declaration")
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index 2364977746..c2d14ea02b 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -3,7 +3,7 @@
from dataclasses import dataclass, field
from functools import lru_cache, partial
from operator import attrgetter
-from typing import TYPE_CHECKING, Any, Callable, Iterable, NamedTuple, cast
+from typing import TYPE_CHECKING, Any, Callable, Iterable, cast
import rich.repr
from rich.style import Style
@@ -33,6 +33,7 @@
ScalarProperty,
ScrollbarColorProperty,
SpacingProperty,
+ SplitProperty,
StringEnumProperty,
StyleFlagsProperty,
TransitionsProperty,
@@ -58,7 +59,6 @@
BoxSizing,
Constrain,
Display,
- Edge,
Overflow,
Overlay,
ScrollbarGutter,
@@ -122,6 +122,7 @@ class RulesMap(TypedDict, total=False):
max_height: Scalar
dock: str
+ split: str
overflow_x: Overflow
overflow_y: Overflow
@@ -197,12 +198,6 @@ class RulesMap(TypedDict, total=False):
_rule_getter = attrgetter(*RULE_NAMES)
-class DockGroup(NamedTuple):
- name: str
- edge: Edge
- z: int
-
-
class StylesBase:
"""A common base class for Styles and RenderStyles"""
@@ -282,6 +277,7 @@ class StylesBase:
max_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False)
dock = DockProperty()
+ split = SplitProperty()
overflow_x = OverflowProperty(VALID_OVERFLOW, "hidden")
overflow_y = OverflowProperty(VALID_OVERFLOW, "hidden")
@@ -894,6 +890,8 @@ def append_declaration(name: str, value: str) -> None:
append_declaration("offset", f"{x} {y}")
if "dock" in rules:
append_declaration("dock", rules["dock"])
+ if "split" in rules:
+ append_declaration("split", rules["split"])
if "layers" in rules:
append_declaration("layers", " ".join(self.layers))
if "layer" in rules:
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 510f43780f..e5dceebe79 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -206,9 +206,7 @@ def __init__(
self._reactive_connect: (
dict[str, tuple[MessagePump, Reactive | object]] | None
) = None
-
self._pruning = False
-
super().__init__()
def set_reactive(
diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py
index ba1cf8c25c..16e2604f85 100644
--- a/src/textual/drivers/linux_driver.py
+++ b/src/textual/drivers/linux_driver.py
@@ -247,9 +247,7 @@ def on_terminal_resize(signum, stack) -> None:
self.write("\x1b[?1004h") # Enable FocusIn/FocusOut.
self.write("\x1b[>1u") # https://sw.kovidgoyal.net/kitty/keyboard-protocol/
# Disambiguate escape codes https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
- # self.write(
- # "\x1b[1;u"
- # ) # Causes https://github.com/Textualize/textual/issues/4870 on iTerm
+ self.write("\x1b[=1;u")
self.flush()
self._key_thread = Thread(target=self._run_input_thread)
send_size_event()
diff --git a/src/textual/geometry.py b/src/textual/geometry.py
index 2bf2004acf..96ac2f5be6 100644
--- a/src/textual/geometry.py
+++ b/src/textual/geometry.py
@@ -583,6 +583,22 @@ def __sub__(self, other: object) -> Region:
return Region(x - ox, y - oy, width, height)
return NotImplemented
+ def get_spacing_between(self, region: Region) -> Spacing:
+ """Get spacing between two regions.
+
+ Args:
+ region: Another region.
+
+ Returns:
+ Spacing that if subtracted from `self` produces `region`.
+ """
+ return Spacing(
+ region.y - self.y,
+ self.right - region.right,
+ self.bottom - region.bottom,
+ region.x - self.x,
+ )
+
def at_offset(self, offset: tuple[int, int]) -> Region:
"""Get a new Region with the same size at a given offset.
diff --git a/src/textual/keys.py b/src/textual/keys.py
index 0006b9a645..72e4cdc395 100644
--- a/src/textual/keys.py
+++ b/src/textual/keys.py
@@ -259,10 +259,12 @@ def value(self) -> str:
"left": "←",
"right": "→",
"backspace": "⌫",
- "escape": "ESC",
+ "escape": "esc",
"enter": "⏎",
"minus": "-",
- "space": "SPACE",
+ "space": "space",
+ "pagedown": "pgdn",
+ "pageup": "pgup",
}
@@ -279,12 +281,24 @@ def _get_key_aliases(key: str) -> list[str]:
return [key] + KEY_ALIASES.get(key, [])
-def _get_key_display(key: str) -> str:
+def _get_key_display(
+ key: str,
+ upper_case_keys: bool = False,
+ ctrl_to_caret: bool = True,
+) -> 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:
- return "+".join([_get_key_display(key) for key in key.split("+")])
+ 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:
@@ -300,7 +314,7 @@ def _get_key_display(key: str) -> str:
# 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
+ return unicode_character.upper() if upper_case_keys else unicode_character
return tentative_unicode_name
diff --git a/src/textual/render.py b/src/textual/render.py
index c1003b062f..c12bbbd453 100644
--- a/src/textual/render.py
+++ b/src/textual/render.py
@@ -30,13 +30,12 @@ def measure(
renderable = rich_cast(renderable)
get_console_width = getattr(renderable, "__rich_measure__", None)
if get_console_width is not None:
- render_width = get_console_width(
- console,
- (
- console.options
- if container_width is None
- else console.options.update_width(container_width)
- ),
- ).maximum
+ options = (
+ console.options
+ if container_width is None
+ else console.options.update_width(container_width)
+ )
+ render_width = get_console_width(console, options).maximum
width = max(0, render_width)
+
return width
diff --git a/src/textual/system_commands.py b/src/textual/system_commands.py
index 69cc17c953..705303c29e 100644
--- a/src/textual/system_commands.py
+++ b/src/textual/system_commands.py
@@ -13,6 +13,8 @@
from __future__ import annotations
+from typing import Iterable
+
from .command import DiscoveryHit, Hit, Hits, Provider
from .types import IgnoreReturnCallbackType
@@ -24,33 +26,44 @@ class SystemCommands(Provider):
"""
@property
- def _system_commands(self) -> tuple[tuple[str, IgnoreReturnCallbackType, str], ...]:
+ def _system_commands(self) -> Iterable[tuple[str, IgnoreReturnCallbackType, str]]:
"""The system commands to reveal to the command palette."""
- return (
- (
- "Toggle light/dark mode",
+ if self.app.dark:
+ yield (
+ "Light mode",
+ self.app.action_toggle_dark,
+ "Switch to a light background",
+ )
+ else:
+ yield (
+ "Dark mode",
self.app.action_toggle_dark,
- "Toggle the application between light and dark mode",
- ),
- (
- "Quit the application",
- self.app.action_quit,
- "Quit the application as soon as possible",
- ),
- (
- "Ring the bell",
- self.app.action_bell,
- "Ring the terminal's 'bell'",
- ),
+ "Switch to a dark background",
+ )
+
+ yield (
+ "Quit the application",
+ self.app.action_quit,
+ "Quit the application as soon as possible",
)
+ if self.screen.query("KeyPanel"):
+ yield ("Hide keys", self.app.action_hide_keys, "Hide the keys panel")
+ else:
+ yield (
+ "Show keys",
+ self.app.action_show_keys,
+ "Show a summary of available keys",
+ )
+
async def discover(self) -> Hits:
"""Handle a request for the discovery commands for this provider.
Yields:
Commands that can be discovered.
"""
- for name, runnable, help_text in self._system_commands:
+ commands = sorted(self._system_commands, key=lambda command: command[0])
+ for name, runnable, help_text in commands:
yield DiscoveryHit(
name,
runnable,
diff --git a/src/textual/widget.py b/src/textual/widget.py
index f3f8196aed..0668e3bbc8 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -1233,6 +1233,7 @@ def _get_box_model(
content_width = Fraction(content_container.width - margin.width)
elif is_auto_width:
# When width is auto, we want enough space to always fit the content
+
content_width = Fraction(
self.get_content_width(content_container - margin.totals, viewport)
)
@@ -1348,7 +1349,6 @@ def get_content_width(self, container: Size, viewport: Size) -> int:
"""
if self.is_container:
- assert self._layout is not None
width = self._layout.get_content_width(self, container, viewport)
return width
@@ -1368,6 +1368,7 @@ def get_content_width(self, container: Size, viewport: Size) -> int:
width = min(width, container.width)
self._content_width_cache = (cache_key, width)
+
return width
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
@@ -1997,7 +1998,6 @@ def layers(self) -> tuple[str, ...]:
break
if node.styles.has_rule("layers"):
layers = node.styles.layers
-
return layers
@property
diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py
index a9505aa0dc..bff80be2ce 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -21,6 +21,7 @@
from ._footer import Footer
from ._header import Header
from ._input import Input
+ from ._key_panel import KeyPanel
from ._label import Label
from ._list_item import ListItem
from ._list_view import ListView
@@ -59,6 +60,7 @@
"Footer",
"Header",
"Input",
+ "KeyPanel",
"Label",
"ListItem",
"ListView",
diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi
index 93b3af4d66..f09f042d97 100644
--- a/src/textual/widgets/__init__.pyi
+++ b/src/textual/widgets/__init__.pyi
@@ -10,6 +10,7 @@ from ._directory_tree import DirectoryTree as DirectoryTree
from ._footer import Footer as Footer
from ._header import Header as Header
from ._input import Input as Input
+from ._key_panel import KeyPanel as KeyPanel
from ._label import Label as Label
from ._list_item import ListItem as ListItem
from ._list_view import ListView as ListView
diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py
index c26ec2cd30..3db09a2938 100644
--- a/src/textual/widgets/_footer.py
+++ b/src/textual/widgets/_footer.py
@@ -151,8 +151,7 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False):
grid-gutter: 1;
}
FooterKey.-command-palette {
- dock: right;
-
+ dock: right;
padding-right: 1;
border-left: vkey $foreground 20%;
}
@@ -251,6 +250,8 @@ def compose(self) -> ComposeResult:
def on_mount(self) -> None:
async def bindings_changed(screen: Screen) -> None:
self._bindings_ready = True
+ if not screen.app.app_focus:
+ return
if self.is_attached and screen is self.screen:
await self.recompose()
diff --git a/src/textual/widgets/_key_panel.py b/src/textual/widgets/_key_panel.py
new file mode 100644
index 0000000000..7f4441322c
--- /dev/null
+++ b/src/textual/widgets/_key_panel.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import TYPE_CHECKING
+
+from rich.table import Table
+from rich.text import Text
+
+from ..app import ComposeResult
+from ..binding import Binding
+from ..containers import VerticalScroll
+from ..reactive import reactive
+from ..widgets import Static
+
+if TYPE_CHECKING:
+ from ..screen import Screen
+
+
+class BindingsTable(Static):
+ """A widget to display bindings."""
+
+ COMPONENT_CLASSES = {"bindings-table--key", "bindings-table--description"}
+
+ DEFAULT_CSS = """
+ BindingsTable {
+ width: auto;
+ height: auto;
+ }
+ """
+
+ 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.
+
+ Returns:
+ A Rich Table.
+ """
+ bindings = [
+ (binding, enabled, tooltip)
+ for (_, binding, enabled, tooltip) in self.screen.active_bindings.values()
+ ]
+ action_to_bindings: defaultdict[str, list[tuple[Binding, bool, str]]]
+ action_to_bindings = defaultdict(list)
+ for binding, enabled, tooltip in bindings:
+ action_to_bindings[binding.action].append((binding, enabled, tooltip))
+
+ table = Table.grid(padding=(0, 1))
+ key_style = self.get_component_rich_style("bindings-table--key")
+ description_style = self.get_component_rich_style("bindings-table--description")
+
+ def render_description(binding: Binding) -> Text:
+ """Render description text from a binding."""
+ text = Text.from_markup(
+ binding.description, end="", style=description_style
+ )
+ if binding.tooltip:
+ text.append(" ")
+ text.append(binding.tooltip, "dim")
+ return text
+
+ table.add_column("", justify="right")
+ 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,
+ ),
+ render_description(binding),
+ )
+
+ return table
+
+ def render(self) -> Table:
+ return self.render_bindings_table()
+
+
+class KeyPanel(VerticalScroll, can_focus=False):
+ DEFAULT_CSS = """
+ KeyPanel {
+ split: right;
+ width: 33%;
+ min-width: 30;
+ max-width: 60;
+ border-left: vkey $foreground 30%;
+ padding: 1 1;
+ height: 1fr;
+ padding-right: 1;
+
+ &> BindingsTable > .bindings-table--key {
+ color: $secondary;
+ text-style: bold;
+ padding: 0 1;
+ }
+
+ &> BindingsTable > .bindings-table--description {
+ color: $text;
+ }
+
+ #bindings-table {
+ width: auto;
+ height: auto;
+ }
+ }
+ """
+
+ 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,
+ )
+
+ async def on_mount(self) -> None:
+ async def bindings_changed(screen: Screen) -> None:
+ if not screen.app.app_focus:
+ return
+ if self.is_attached and screen is self.screen:
+ self.refresh(recompose=True)
+
+ self.screen.bindings_updated_signal.subscribe(self, bindings_changed)
+
+ def on_unmount(self) -> None:
+ self.screen.bindings_updated_signal.unsubscribe(self)
diff --git a/tests/command_palette/test_escaping.py b/tests/command_palette/test_escaping.py
index 83bf36ad15..11fb1ded87 100644
--- a/tests/command_palette/test_escaping.py
+++ b/tests/command_palette/test_escaping.py
@@ -23,28 +23,3 @@ async def test_escape_closes_when_no_list_visible() -> None:
assert CommandPalette.is_open(pilot.app)
await pilot.press("escape")
assert not CommandPalette.is_open(pilot.app)
-
-
-async def test_escape_does_not_close_when_list_visible() -> None:
- """Pressing escape when a hit list is visible should not close the command palette."""
- async with CommandPaletteApp().run_test() as pilot:
- assert CommandPalette.is_open(pilot.app)
- await pilot.press("a")
- await pilot.press("escape")
- assert CommandPalette.is_open(pilot.app)
- await pilot.press("escape")
- assert not CommandPalette.is_open(pilot.app)
-
-
-async def test_down_arrow_should_undo_closing_of_list_via_escape() -> None:
- """Down arrow should reopen the hit list if escape closed it before."""
- async with CommandPaletteApp().run_test() as pilot:
- assert CommandPalette.is_open(pilot.app)
- await pilot.press("a")
- await pilot.press("escape")
- assert CommandPalette.is_open(pilot.app)
- await pilot.press("down")
- await pilot.press("escape")
- assert CommandPalette.is_open(pilot.app)
- await pilot.press("escape")
- assert not CommandPalette.is_open(pilot.app)
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_auto_tab_active.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_auto_tab_active.svg
index 7d9500e809..0d1ef0520c 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_auto_tab_active.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_auto_tab_active.svg
@@ -19,144 +19,144 @@
font-weight: 700;
}
- .terminal-1993010201-matrix {
+ .terminal-304540857-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1993010201-title {
+ .terminal-304540857-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1993010201-r1 { fill: #c5c8c6 }
-.terminal-1993010201-r2 { fill: #e1e1e1 }
-.terminal-1993010201-r3 { fill: #f4005f }
-.terminal-1993010201-r4 { fill: #98e024;font-weight: bold }
-.terminal-1993010201-r5 { fill: #323232 }
-.terminal-1993010201-r6 { fill: #0178d4 }
-.terminal-1993010201-r7 { fill: #98e024 }
-.terminal-1993010201-r8 { fill: #7ae998 }
-.terminal-1993010201-r9 { fill: #4ebf71;font-weight: bold }
-.terminal-1993010201-r10 { fill: #008139 }
-.terminal-1993010201-r11 { fill: #fea62b;font-weight: bold }
-.terminal-1993010201-r12 { fill: #a7a9ab }
-.terminal-1993010201-r13 { fill: #e2e3e3 }
-.terminal-1993010201-r14 { fill: #4c5055 }
+ .terminal-304540857-r1 { fill: #c5c8c6 }
+.terminal-304540857-r2 { fill: #e1e1e1 }
+.terminal-304540857-r3 { fill: #f4005f }
+.terminal-304540857-r4 { fill: #98e024;font-weight: bold }
+.terminal-304540857-r5 { fill: #323232 }
+.terminal-304540857-r6 { fill: #0178d4 }
+.terminal-304540857-r7 { fill: #98e024 }
+.terminal-304540857-r8 { fill: #7ae998 }
+.terminal-304540857-r9 { fill: #4ebf71;font-weight: bold }
+.terminal-304540857-r10 { fill: #008139 }
+.terminal-304540857-r11 { fill: #fea62b;font-weight: bold }
+.terminal-304540857-r12 { fill: #a7a9ab }
+.terminal-304540857-r13 { fill: #e2e3e3 }
+.terminal-304540857-r14 { fill: #4c5055 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- ExampleApp
+ ExampleApp
-
+
-
-
-Parent 1Parent 2
-━━━━━━━━━━━━╸━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
-
-Child 2.1Child 2.2
-━━━━━━━━━━━━━╸━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
-▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- Button 2.2
-▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
-
-
-
-
-
-
-
-
-
-
-
-
- SPACE Focus button 2.2 ▏^p palette
+
+
+Parent 1Parent 2
+━━━━━━━━━━━━╸━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+
+Child 2.1Child 2.2
+━━━━━━━━━━━━━╸━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+ Button 2.2
+▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+
+
+
+
+
+
+
+
+
+
+
+
+ space Focus button 2.2 ▏^p palette
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_bind_override.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_bind_override.svg
index d0e1bbf708..524cba14e9 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_bind_override.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_bind_override.svg
@@ -19,139 +19,139 @@
font-weight: 700;
}
- .terminal-1520345308-matrix {
+ .terminal-508010876-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1520345308-title {
+ .terminal-508010876-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1520345308-r1 { fill: #008000 }
-.terminal-1520345308-r2 { fill: #c5c8c6 }
-.terminal-1520345308-r3 { fill: #e1e1e1 }
-.terminal-1520345308-r4 { fill: #1e1e1e }
-.terminal-1520345308-r5 { fill: #e2e2e2 }
-.terminal-1520345308-r6 { fill: #fea62b;font-weight: bold }
-.terminal-1520345308-r7 { fill: #a7a9ab }
-.terminal-1520345308-r8 { fill: #e2e3e3 }
-.terminal-1520345308-r9 { fill: #4c5055 }
+ .terminal-508010876-r1 { fill: #008000 }
+.terminal-508010876-r2 { fill: #c5c8c6 }
+.terminal-508010876-r3 { fill: #e1e1e1 }
+.terminal-508010876-r4 { fill: #1e1e1e }
+.terminal-508010876-r5 { fill: #e2e2e2 }
+.terminal-508010876-r6 { fill: #fea62b;font-weight: bold }
+.terminal-508010876-r7 { fill: #a7a9ab }
+.terminal-508010876-r8 { fill: #e2e3e3 }
+.terminal-508010876-r9 { fill: #4c5055 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- BindApp
+ BindApp
-
+
-
- ┌──────────────────────────────────────────────────────────────────────────────┐
-│MyWidget│
-││
-││
-└──────────────────────────────────────────────────────────────────────────────┘
-▊▔▔▔▔▔▔▔▔▎
-▊▎
-▊▁▁▁▁▁▁▁▁▎
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- SPACE Bell (Widget) a widget b widget c app ▏^p palette
+
+ ┌──────────────────────────────────────────────────────────────────────────────┐
+│MyWidget│
+││
+││
+└──────────────────────────────────────────────────────────────────────────────┘
+▊▔▔▔▔▔▔▔▔▎
+▊▎
+▊▁▁▁▁▁▁▁▁▎
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ space Bell (Widget) a widget b widget c app ▏^p palette
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 05a2d698a3..ee0ad99610 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-881099794-matrix {
+ .terminal-407601934-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-881099794-title {
+ .terminal-407601934-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-881099794-r1 { fill: #e1e1e1 }
-.terminal-881099794-r2 { fill: #c5c8c6 }
-.terminal-881099794-r3 { fill: #dde8f3;font-weight: bold }
-.terminal-881099794-r4 { fill: #ddedf9 }
-.terminal-881099794-r5 { fill: #308fd9 }
+ .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 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- ClassicFooterStylingApp
+ ClassicFooterStylingApp
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- CTRL+T Toggle Dark mode CTRL+Q Quit ▏^p palette
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ^T Toggle Dark mode ^Q Quit ▏^p palette
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_programmatic_disable_button.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_programmatic_disable_button.svg
index cc8a137420..163c8a7c33 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_programmatic_disable_button.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_programmatic_disable_button.svg
@@ -19,139 +19,139 @@
font-weight: 700;
}
- .terminal-400643882-matrix {
+ .terminal-3006158794-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-400643882-title {
+ .terminal-3006158794-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-400643882-r1 { fill: #e1e1e1 }
-.terminal-400643882-r2 { fill: #c5c8c6 }
-.terminal-400643882-r3 { fill: #303336 }
-.terminal-400643882-r4 { fill: #a7a7a7;font-weight: bold }
-.terminal-400643882-r5 { fill: #0f0f0f }
-.terminal-400643882-r6 { fill: #fea62b;font-weight: bold }
-.terminal-400643882-r7 { fill: #a7a9ab }
-.terminal-400643882-r8 { fill: #e2e3e3 }
-.terminal-400643882-r9 { fill: #4c5055 }
+ .terminal-3006158794-r1 { fill: #e1e1e1 }
+.terminal-3006158794-r2 { fill: #c5c8c6 }
+.terminal-3006158794-r3 { fill: #303336 }
+.terminal-3006158794-r4 { fill: #a7a7a7;font-weight: bold }
+.terminal-3006158794-r5 { fill: #0f0f0f }
+.terminal-3006158794-r6 { fill: #fea62b;font-weight: bold }
+.terminal-3006158794-r7 { fill: #a7a9ab }
+.terminal-3006158794-r8 { fill: #e2e3e3 }
+.terminal-3006158794-r9 { fill: #4c5055 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- ExampleApp
+ ExampleApp
-
+
-
-
-
-
-
-
-
-
-
-
- Hover the button then hit space
-▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- Disabled
-▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
-
-
-
-
-
-
-
-
-
-
- SPACE Toggle Button ▏^p palette
+
+
+
+
+
+
+
+
+
+
+ Hover the button then hit space
+▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+ Disabled
+▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+
+
+
+
+
+
+
+
+
+
+ space Toggle Button ▏^p palette
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_split.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_split.svg
new file mode 100644
index 0000000000..d1f69e5946
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_split.svg
@@ -0,0 +1,181 @@
+
diff --git a/tests/snapshot_tests/snapshot_apps/split.py b/tests/snapshot_tests/snapshot_apps/split.py
new file mode 100644
index 0000000000..ed9febf703
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/split.py
@@ -0,0 +1,48 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Placeholder
+
+
+class SplitApp(App):
+ CSS = """
+
+ #split1 {
+ split: right;
+ width: 20;
+ }
+
+ #split2 {
+ split: bottom;
+ height: 10;
+ }
+
+ #split3 {
+ split: top;
+ height: 6;
+ }
+
+ #split4 {
+ split: left;
+ width: 30;
+ }
+
+ .scrollable {
+ height: 5;
+ }
+
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Placeholder(id="split1")
+ yield Placeholder(id="split2")
+ yield Placeholder(id="split3")
+ yield Placeholder(id="split4")
+ yield Placeholder("1", classes="scrollable")
+ yield Placeholder("2", classes="scrollable")
+ yield Placeholder("3", classes="scrollable")
+ yield Placeholder("4", classes="scrollable")
+ yield Placeholder("5", classes="scrollable")
+
+
+if __name__ == "__main__":
+ app = SplitApp()
+ app.run()
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index a818ab788e..80eb674a2d 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -1438,3 +1438,8 @@ async def run_before(pilot: Pilot):
assert snap_compare(
SNAPSHOT_APPS_DIR / "command_palette_dismiss.py", run_before=run_before
)
+
+
+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_geometry.py b/tests/test_geometry.py
index 7f5ef264da..da3a35e026 100644
--- a/tests/test_geometry.py
+++ b/tests/test_geometry.py
@@ -504,3 +504,16 @@ def test_size_clamp_offset():
assert Size(3, 3).clamp_offset(Offset(3, 2)) == Offset(2, 2)
assert Size(3, 3).clamp_offset(Offset(-3, 2)) == Offset(0, 2)
assert Size(3, 3).clamp_offset(Offset(5, 4)) == Offset(2, 2)
+
+
+@pytest.mark.parametrize(
+ ("region1", "region2", "expected"),
+ [
+ (Region(0, 0, 100, 80), Region(0, 0, 100, 80), Spacing(0, 0, 0, 0)),
+ (Region(0, 0, 100, 80), Region(10, 10, 10, 10), Spacing(10, 80, 60, 10)),
+ ],
+)
+def test_get_spacing_between(region1: Region, region2: Region, expected: Spacing):
+ spacing = region1.get_spacing_between(region2)
+ assert spacing == expected
+ assert region1.shrink(spacing) == region2
diff --git a/tests/test_keys.py b/tests/test_keys.py
index 54068b1901..5d9f8be194 100644
--- a/tests/test_keys.py
+++ b/tests/test_keys.py
@@ -64,8 +64,8 @@ def test_get_key_display_when_used_in_conjunction():
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"ctrl+{right_square_bracket}"
+ assert ctrl_right_square_bracket == f"^{right_square_bracket}"
left = _get_key_display("left")
ctrl_left = _get_key_display("ctrl+left")
- assert ctrl_left == f"ctrl+{left}"
+ assert ctrl_left == f"^{left}"