Skip to content
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
5 changes: 5 additions & 0 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ def key_mode(self) -> type[EditingMode]:
"""Ordinary controls use their screen's declared keyboard scope."""
return self.screen.key_mode

@property
def key_mode_label(self) -> str:
"""Display the current keyboard scope without interpreting its identity."""
return self.key_mode().label

@property
def binding_mode(self) -> type[EditingMode]:
"""The canonical editing mode used to project this node's bindings."""
Expand Down
6 changes: 6 additions & 0 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ def key_mode(self) -> type[EditingMode]:
"""Screens start with ordinary keys; an application scope can select a profile."""
return EditingMode

@property
def active_key_mode_label(self) -> str:
"""Project the focused owner's mode, or this screen's keyboard scope."""
focused = self.focused
return focused.key_mode_label if focused is not None else self.key_mode_label

@property
def active_binding_mode(self) -> type[EditingMode]:
"""Every scope uses the focused editor's actual mode for this binding chain."""
Expand Down
58 changes: 36 additions & 22 deletions src/textual/widgets/_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class FooterBindingGroup:
class FooterProjection:
groups: tuple[FooterBindingGroup, ...]
palette: FooterBinding | None
mode_label: str
columns: int


Expand Down Expand Up @@ -281,6 +282,9 @@ def compose(self) -> ComposeResult:
else:
for item in part.bindings:
yield self._make_key(item)
mode_label = FooterLabel(projection.mode_label, classes="-mode")
mode_label.display = bool(projection.mode_label)
yield mode_label
if projection.palette is not None:
yield self._make_key(projection.palette, palette=True)

Expand Down Expand Up @@ -337,7 +341,10 @@ def _project_bindings(self) -> FooterProjection:
palette_display = FooterBinding(
binding, enabled, tooltip, self.app.get_key_display(binding)
)
return FooterProjection(tuple(groups), palette_display, len(by_action))
mode_label = self.screen.active_key_mode_label
return FooterProjection(
tuple(groups), palette_display, mode_label, len(by_action) + bool(mode_label)
)

async def _reconcile_bindings(self) -> None:
revision = self._binding_revision
Expand Down Expand Up @@ -376,28 +383,35 @@ async def _apply_binding_projection(self) -> None:
if not same_groups:
await self.recompose()
return
parents = iter(self.children)
for part in projection.groups:
if part.group is not None:
parent = next(parents)
assert isinstance(parent, KeyGroup)
await self._reconcile_keys(parent, part.bindings, grouped=True)
next(parents) # The unchanged group's description label.
else:
# A flat footer is the common case; grouped/flat structural
# transitions retain ordinary recomposition above.
if len(projection.groups) != 1:
await self.recompose()
if previous is None or (
previous.groups != projection.groups or previous.palette != projection.palette
):
parents = iter(self.children)
for part in projection.groups:
if part.group is not None:
parent = next(parents)
assert isinstance(parent, KeyGroup)
await self._reconcile_keys(parent, part.bindings, grouped=True)
next(parents) # The unchanged group's description label.
else:
# A flat footer is the common case; grouped/flat structural
# transitions retain ordinary recomposition above.
if len(projection.groups) != 1:
await self.recompose()
return
await self._reconcile_keys(self, part.bindings)
if not self.is_attached or self._closing or self._pruning:
return
await self._reconcile_keys(self, part.bindings)
if not self.is_attached or self._closing or self._pruning:
return
if projection.palette is not None:
self._update_key(
self.query_one(".-command-palette", FooterKey),
projection.palette,
palette=True,
)
if projection.palette is not None:
self._update_key(
self.query_one(".-command-palette", FooterKey),
projection.palette,
palette=True,
)
mode_label = self.query_one(".-mode", FooterLabel)
if previous is None or previous.mode_label != projection.mode_label:
mode_label.update(projection.mode_label)
mode_label.display = bool(projection.mode_label)
self.styles.grid_size_columns = projection.columns
self._binding_projection = projection

Expand Down
75 changes: 64 additions & 11 deletions src/textual/widgets/_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from textual.binding import ModeBinding
from textual.document._editing import VimMode
from textual.content import Content
from textual.css.scalar import Scalar
from textual.geometry import Offset
from textual.widget import Widget

Expand Down Expand Up @@ -108,31 +109,78 @@ class HintSession(Widget, can_focus=True):
def __init__(self, screen, targets, click: HintClick):
super().__init__()
self.click = click
self.original_focus = screen.focused
self.original_focus: Widget | None = screen.focused
targets = sorted(targets, key=lambda target: (target.region.y, target.region.x))
self.prefix = ""
width = 1
while len(self.alphabet) ** width < len(targets):
width += 1
size = screen.size
placed: dict[int, list[tuple[int, int]]] = {
size.height - 1: [(0, min(size.width, len(click.caption)))]
}
labels = []
for index, target in enumerate(targets):
text = self.label(index, width)
offset = self.free_offset(placed, size, target.region.offset, len(text))
labels.append(HintLabel(self, text, target, offset))
labels.append(HintLabel(self, text, target, target.region.offset))
self.labels = tuple(labels)
self.styles.position = "absolute"
self.styles.overlay = "screen"
self.styles.layer = "_tooltips"
self.styles.width = min(size.width, len(click.caption))
self.styles.height = 1
self.absolute_offset = Offset(0, size.height - 1)

@property
def _current_original_focus(self) -> Widget | None:
original = self.original_focus
if (
original is not None
and original.is_attached
and original.screen is self.screen
and self.screen is self.app.screen
):
return original
return None

@property
def key_mode_label(self) -> str:
"""Taking hint focus does not replace the originating editor's mode."""
original = self._current_original_focus
return original.key_mode_label if original is not None else super().key_mode_label

@property
def caption(self) -> Content:
"""One complete caption supplies paint, width and reserved cells."""
mode_label = self.key_mode_label
return Content(
f"{mode_label} · {self.click.caption}" if mode_label else self.click.caption
)

def render(self) -> Content:
return Content(self.click.caption)
return self.caption

def on_mount(self) -> None:
self.screen.bindings_updated_signal.subscribe(self, self._bindings_changed)
self._bindings_changed(self.screen)

def on_unmount(self) -> None:
self.screen.bindings_updated_signal.unsubscribe(self)

def _bindings_changed(self, _screen: Widget) -> None:
if self.disabled:
return
self._reconcile_caption_geometry()
self.refresh()

def _reconcile_caption_geometry(self) -> None:
size = self.screen.size
width = min(size.width, self.caption.cell_length)
desired_width = Scalar.from_number(width)
offset = Offset(0, size.height - 1)
if self.styles.width == desired_width and self.absolute_offset == offset:
return
self.styles.width = desired_width
self.absolute_offset = offset
placed: dict[int, list[tuple[int, int]]] = {offset.y: [(0, width)]}
for label in self.labels:
label.absolute_offset = self.free_offset(
placed, size, label.target.region.offset, len(label.text)
)

@staticmethod
def free_offset(placed, size, origin, width):
Expand Down Expand Up @@ -204,7 +252,7 @@ def restore_focus(self):
if self.screen is not self.app.screen:
return
original = self.original_focus
if original is None or (original.is_attached and original.screen is self.screen):
if original is None or self._current_original_focus is not None:
self.screen.set_focus(original, scroll_visible=False)

def dismiss(self, *, target=None, restore=True):
Expand All @@ -228,6 +276,11 @@ def on_blur(self):
def reconcile_publication(self):
if self.disabled:
return
if self.original_focus is not None and self._current_original_focus is None:
self.original_focus = None
self.refresh_bindings()
self.refresh()
self._reconcile_caption_geometry()
for label in self.labels:
if label.display and not label.target.available():
label.display = False
Expand Down
5 changes: 5 additions & 0 deletions src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ def key_mode(self) -> type[EditingMode]:
"""Editor bindings follow the live editor state, including native transitions."""
return type(self.editing_mode)

@property
def key_mode_label(self) -> str:
"""Read the actual state, including parameterized pending modes."""
return self.editing_mode.label

@property
def binding_mode(self) -> type[EditingMode]:
return self.editing_mode.binding_mode
Expand Down
97 changes: 96 additions & 1 deletion tests/test_hints.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from dataclasses import dataclass
from itertools import combinations

import pytest

from textual.app import App, ComposeResult
from textual.css.scalar import Scalar
from textual.document._editing import EditingMode
from textual.geometry import Region
from textual.widgets import Static, Input, TextArea, Checkbox, RadioButton, Switch, Collapsible, Select
from textual.widgets._collapsible import CollapsibleTitle
from textual.widgets._hints import HintLabel, HintSession
from textual.widgets._hints import HintClick, HintLabel, HintSession


class DenseLinksApp(App):
Expand Down Expand Up @@ -75,3 +81,92 @@ async def choose(widget):
await choose(select)
assert select.expanded
await pilot.press('escape')


@dataclass(frozen=True)
class HintCaptionMode(EditingMode):
"""A label-only mode leaves native editing and binding behavior unchanged."""

text: str

@property
def label(self) -> str:
return self.text


class HintCaptionApp(App[None]):
CSS = "TextArea { height: 4; } Static { margin-top: 1; }"

def compose(self) -> ComposeResult:
yield TextArea("draft stays")
yield Static("[@click=app.notify('target')]Hint target[/]")


@pytest.mark.parametrize("click", tuple(HintClick))
async def test_hint_caption_initial_unset_width_mount(click: HintClick) -> None:
app = HintCaptionApp()
async with app.run_test(size=(80, 24)) as pilot:
editor = app.query_one(TextArea)
editor.editing_mode = HintCaptionMode("NORMAL")
editor.focus()
await pilot.pause()
targets = app.screen._compositor.activation_targets(click)
assert targets, "The session must borrow actual published targets"
session = HintSession(app.screen, targets, click)
assert session.styles.width is None

await app.screen.mount(*session.labels, session)
session.focus_on_host()
await pilot.pause()

assert app.screen.query_one(HintSession) is session
assert app.screen.focused is session
assert session.original_focus is editor
assert session.styles.width == Scalar.from_number(
min(app.screen.size.width, session.caption.cell_length)
)
assert session.render().plain == f"{editor.editing_mode.label} · {click.caption}"
await pilot.press("escape")
await pilot.pause()
assert not app.screen.query_children(HintSession)
assert editor.has_focus


@pytest.mark.parametrize("click", tuple(HintClick))
async def test_hint_caption_same_width_mode_change(click: HintClick) -> None:
app = HintCaptionApp()
async with app.run_test(size=(80, 24)) as pilot:
editor = app.query_one(TextArea)
original_mode = HintCaptionMode("NORMAL")
replacement_mode = HintCaptionMode("INSERT")
editor.editing_mode = original_mode
editor.focus()
await pilot.pause()
assert app.screen._compositor.activation_targets(click)
await app.screen._show_hints(click)
await pilot.pause()
session = app.screen.query_one(HintSession)
width, offset = session.styles.width, session.absolute_offset
positions = tuple(label.absolute_offset for label in session.labels)
crop = Region(0, 0, session.size.width, 1)
assert original_mode.label in "".join(
strip.text for strip in session.render_lines(crop)
)

editor.editing_mode = replacement_mode
await pilot.pause()

assert app.screen.query_one(HintSession) is session
assert app.screen.focused is session
assert session.original_focus is editor
assert session.styles.width == width
assert session.absolute_offset == offset
assert tuple(label.absolute_offset for label in session.labels) == positions
painted = "".join(strip.text for strip in session.render_lines(crop))
assert replacement_mode.label in painted
assert original_mode.label not in painted
assert session.render().plain == f"{replacement_mode.label} · {click.caption}"
await pilot.press("escape")
await pilot.pause()
assert not app.screen.query_children(HintSession)
assert editor.has_focus