Skip to content

Commit

Permalink
Merge branch 'main' into disallow-screen-instances
Browse files Browse the repository at this point in the history
  • Loading branch information
ZeroIntensity authored Aug 21, 2024
2 parents 7180ecc + 15c3d57 commit f739b77
Show file tree
Hide file tree
Showing 42 changed files with 1,408 additions and 560 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,19 @@ 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
- 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
- Added `DOMNode.BINDING_GROUP` https://github.com/Textualize/textual/pull/4906

### 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
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
3 changes: 2 additions & 1 deletion src/textual/_binary_encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ def encode_string(datum: str) -> bytes:
Returns:
The encoded bytes.
"""
return b"s%i:%s" % (len(datum), datum.encode("utf-8"))
encoded_data = datum.encode("utf-8")
return b"s%i:%s" % (len(encoded_data), encoded_data)

def encode_list(datum: list) -> bytes:
"""
Expand Down
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
86 changes: 64 additions & 22 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 All @@ -388,6 +383,8 @@ class MyApp(App[None]):
TOOLTIP_DELAY: float = 0.5
"""The time in seconds after which a tooltip gets displayed."""

BINDING_GROUP_TITLE = "App"

title: Reactive[str] = Reactive("", compute=False)
sub_title: Reactive[str] = Reactive("", compute=False)

Expand Down Expand Up @@ -652,6 +649,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 __init_subclass__(cls, *args, **kwargs) -> None:
for variable_name, screen_collection in (
("SCREENS", cls.SCREENS),
Expand Down Expand Up @@ -1339,21 +1353,35 @@ def bind(
keys, action, description, show=show, key_display=key_display
)

def get_key_display(self, key: str) -> 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.
binding: A Binding.
Returns:
The display string for the input key.
A string used to represent the key.
"""
return _get_key_display(key)
# 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 Expand Up @@ -2209,7 +2237,8 @@ async def push_screen_wait(
The screen's result.
"""
await self._flush_next_callbacks()
return await self.push_screen(screen, wait_for_dismiss=True)
# The shield prevents the cancellation of the current task from canceling the push_screen awaitable
return await asyncio.shield(self.push_screen(screen, wait_for_dismiss=True))

def switch_screen(self, screen: Screen | str) -> AwaitComplete:
"""Switch to another [screen](/guide/screens) by replacing the top of the screen stack with a new screen.
Expand Down Expand Up @@ -3521,6 +3550,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
Loading

0 comments on commit f739b77

Please sign in to comment.