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

Key panel widget #4876

Merged
merged 29 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 @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this subject to the same issue as what we discussed earlier regarding having the first option always highlighted by default? If they can come in asynchronously, then when the user presses enter there may only be 1, but this doesn't mean there will always be one.

I think in VSCode and the browser URL bar, it also does things asynchronously, but it just selects the one that was at the top when the user presses enter.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does introduce a 100ms window where something could change. I'll see if I can remove that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the timer for now. In a future update, I'll implement the logic to highlight the first item.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading