From e196a478be70db1cc37800f28f3172b328213bfd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:29:37 -0400 Subject: [PATCH 1/3] Project actual editor mode into native footer and hint captions --- src/textual/dom.py | 5 +++ src/textual/screen.py | 6 ++++ src/textual/widgets/_footer.py | 58 ++++++++++++++++++------------ src/textual/widgets/_hints.py | 60 ++++++++++++++++++++++++++----- src/textual/widgets/_text_area.py | 5 +++ 5 files changed, 103 insertions(+), 31 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index f2ccbc9e96..2e0778f673 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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.""" diff --git a/src/textual/screen.py b/src/textual/screen.py index 71c3da882a..a004f742e5 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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.""" diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index ca315c76b8..79a5894523 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -40,6 +40,7 @@ class FooterBindingGroup: class FooterProjection: groups: tuple[FooterBindingGroup, ...] palette: FooterBinding | None + mode_label: str columns: int @@ -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) @@ -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 @@ -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 diff --git a/src/textual/widgets/_hints.py b/src/textual/widgets/_hints.py index 392b74e4bd..f968ae002e 100644 --- a/src/textual/widgets/_hints.py +++ b/src/textual/widgets/_hints.py @@ -114,25 +114,66 @@ def __init__(self, screen, targets, click: HintClick): 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 key_mode_label(self) -> str: + """Taking hint focus does not replace the originating editor's mode.""" + 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.key_mode_label + return 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) + offset = Offset(0, size.height - 1) + if self.styles.width.value == width and self.absolute_offset == offset: + return + self.styles.width = 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): @@ -228,6 +269,7 @@ def on_blur(self): def reconcile_publication(self): if self.disabled: return + self._reconcile_caption_geometry() for label in self.labels: if label.display and not label.target.available(): label.display = False diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 9a9280762c..a980d7bafd 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -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 From 197849b89329f22806b4c2bf78bab3a2fbac9085 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:33:02 -0400 Subject: [PATCH 2/3] Retire lost hint focus and invalidate its caption once --- src/textual/widgets/_hints.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_hints.py b/src/textual/widgets/_hints.py index f968ae002e..ae94633317 100644 --- a/src/textual/widgets/_hints.py +++ b/src/textual/widgets/_hints.py @@ -108,7 +108,7 @@ 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 @@ -125,8 +125,7 @@ def __init__(self, screen, targets, click: HintClick): self.styles.height = 1 @property - def key_mode_label(self) -> str: - """Taking hint focus does not replace the originating editor's mode.""" + def _current_original_focus(self) -> Widget | None: original = self.original_focus if ( original is not None @@ -134,8 +133,14 @@ def key_mode_label(self) -> str: and original.screen is self.screen and self.screen is self.app.screen ): - return original.key_mode_label - return super().key_mode_label + 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: @@ -245,7 +250,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): @@ -269,6 +274,10 @@ 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(): From bc4f72dde5332eda49c9918a44b10136f1f9b4b4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 16:52:02 -0400 Subject: [PATCH 3/3] Fix hint caption geometry before initial width assignment --- src/textual/widgets/_hints.py | 6 ++- tests/test_hints.py | 97 ++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_hints.py b/src/textual/widgets/_hints.py index ae94633317..eac823d924 100644 --- a/src/textual/widgets/_hints.py +++ b/src/textual/widgets/_hints.py @@ -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 @@ -169,10 +170,11 @@ def _bindings_changed(self, _screen: Widget) -> None: 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.value == width and self.absolute_offset == offset: + if self.styles.width == desired_width and self.absolute_offset == offset: return - self.styles.width = width + 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: diff --git a/tests/test_hints.py b/tests/test_hints.py index fd820caa3b..dbc645ee62 100644 --- a/tests/test_hints.py +++ b/tests/test_hints.py @@ -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): @@ -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