Skip to content

Commit 3c7190a

Browse files
authored
Merge branch 'main' into footer-scroll
2 parents 0956ec4 + 5889c48 commit 3c7190a

17 files changed

+423
-22
lines changed

CHANGELOG.md

+15-1
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## Unreleased
99

10+
11+
### Fixed
12+
13+
- Fixed `Pilot.click` not working with `times` parameter https://github.com/Textualize/textual/pull/5398
14+
15+
### Added
16+
17+
- Added `from_app_focus` to `Focus` event to indicate if a widget is being focused because the app itself has regained focus or not https://github.com/Textualize/textual/pull/5379
18+
- - Added `Select.type_to_search` which allows you to type to move the cursor to a matching option https://github.com/Textualize/textual/pull/5403
19+
1020
### Changed
1121

22+
- The content of an `Input` will now only be automatically selected when the widget is focused by the user, not when the app itself has regained focus (similar to web browsers). https://github.com/Textualize/textual/pull/5379
23+
- Updated `TextArea` and `Input` behavior when there is a selection and the user presses left or right https://github.com/Textualize/textual/pull/5400
1224
- Footer can now be scrolled horizontally without holding `shift` https://github.com/Textualize/textual/pull/5404
1325

26+
1427
## [1.0.0] - 2024-12-12
1528

1629
### Added
@@ -20,7 +33,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2033
- Added `system` boolean to Binding, which hides the binding from the help panel https://github.com/Textualize/textual/pull/5352
2134
- Added support for double/triple/etc clicks via `chain` attribute on `Click` events https://github.com/Textualize/textual/pull/5369
2235
- Added `times` parameter to `Pilot.click` method, for simulating rapid clicks https://github.com/Textualize/textual/pull/5369
23-
36+
- Text can now be select using mouse or keyboard in the Input widget https://github.com/Textualize/textual/pull/5340
37+
2438
### Changed
2539

2640
- Breaking change: Change default quit key to `ctrl+q` https://github.com/Textualize/textual/pull/5352

docs/guide/queries.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# DOM Queries
22

3-
In the previous chapter we introduced the [DOM](../guide/CSS.md#the-dom) which is how Textual apps keep track of widgets. We saw how you can apply styles to the DOM with CSS [selectors](./CSS.md#selectors).
3+
In the [CSS chapter](./CSS.md) we introduced the [DOM](../guide/CSS.md#the-dom) which is how Textual apps keep track of widgets. We saw how you can apply styles to the DOM with CSS [selectors](./CSS.md#selectors).
44

55
Selectors are a very useful idea and can do more than apply styles. We can also find widgets in Python code with selectors, and make updates to widgets in a simple expressive way. Let's look at how!
66

@@ -19,7 +19,7 @@ We could do this with the following line of code:
1919
send_button = self.query_one("#send")
2020
```
2121

22-
This will retrieve a widget with an ID of `send`, if there is exactly one.
22+
This will retrieve the first widget discovered with an ID of `send`.
2323
If there are no matching widgets, Textual will raise a [NoMatches][textual.css.query.NoMatches] exception.
2424

2525
You can also add a second parameter for the expected type, which will ensure that you get the type you are expecting.
@@ -41,6 +41,15 @@ For instance, the following would return a `Button` instance (assuming there is
4141
my_button = self.query_one(Button)
4242
```
4343

44+
`query_one` searches the DOM *below* the widget it is called on, so if you call `query_one` on a widget, it will only find widgets that are descendants of that widget.
45+
46+
If you wish to search the entire DOM, you should call `query_one` on the `App` or `Screen` instance.
47+
48+
```python
49+
# Search the entire Screen for a widget with an ID of "send-email"
50+
self.screen.query_one("#send-email")
51+
```
52+
4453
## Making queries
4554

4655
Apps and widgets also have a [query][textual.dom.DOMNode.query] method which finds (or queries) widgets. This method returns a [DOMQuery][textual.css.query.DOMQuery] object which is a list-like container of widgets.

docs/widgets/select.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,15 @@ The following example presents a `Select` created using the `from_values` class
8888

8989
## Blank state
9090

91-
The widget `Select` has an option `allow_blank` for its constructor.
91+
The `Select` widget has an option `allow_blank` for its constructor.
9292
If set to `True`, the widget may be in a state where there is no selection, in which case its value will be the special constant [`Select.BLANK`][textual.widgets.Select.BLANK].
9393
The auxiliary methods [`Select.is_blank`][textual.widgets.Select.is_blank] and [`Select.clear`][textual.widgets.Select.clear] provide a convenient way to check if the widget is in this state and to set this state, respectively.
9494

95+
## Type to search
9596

96-
## Reactive Attributes
97+
The `Select` widget has a `type_to_search` attribute which allows you to type to move the cursor to a matching option when the widget is expanded. To disable this behavior, set the attribute to `False`.
9798

99+
## Reactive Attributes
98100

99101
| Name | Type | Default | Description |
100102
|------------|--------------------------------|------------------------------------------------|-------------------------------------|

src/textual/app.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -4052,7 +4052,9 @@ def _watch_app_focus(self, focus: bool) -> None:
40524052
# ...settle focus back on that widget.
40534053
# Don't scroll the newly focused widget, as this can be quite jarring
40544054
self.screen.set_focus(
4055-
self._last_focused_on_app_blur, scroll_visible=False
4055+
self._last_focused_on_app_blur,
4056+
scroll_visible=False,
4057+
from_app_focus=True,
40564058
)
40574059
except NoScreen:
40584060
pass

src/textual/command.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,7 @@ def compose(self) -> ComposeResult:
776776
with Vertical(id="--container"):
777777
with Horizontal(id="--input"):
778778
yield SearchIcon()
779-
yield CommandInput(placeholder=self._placeholder)
779+
yield CommandInput(placeholder=self._placeholder, select_on_focus=False)
780780
if not self.run_on_select:
781781
yield Button("\u25b6")
782782
with Vertical(id="--results"):

src/textual/css/stylesheet.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ def _check_rule(
437437
# These shouldn't be used in a cache key
438438
_EXCLUDE_PSEUDO_CLASSES_FROM_CACHE: Final[set[str]] = {
439439
"first-of-type",
440-
"last-of_type",
440+
"last-of-type",
441441
"odd",
442442
"even",
443443
"focus-within",

src/textual/events.py

+14
Original file line numberDiff line numberDiff line change
@@ -722,8 +722,22 @@ class Focus(Event, bubble=False):
722722
723723
- [ ] Bubbles
724724
- [ ] Verbose
725+
726+
Args:
727+
from_app_focus: True if this focus event has been sent because the app itself has
728+
regained focus (via an AppFocus event). False if the focus came from within
729+
the Textual app (e.g. via the user pressing tab or a programmatic setting
730+
of the focused widget).
725731
"""
726732

733+
def __init__(self, from_app_focus: bool = False) -> None:
734+
self.from_app_focus = from_app_focus
735+
super().__init__()
736+
737+
def __rich_repr__(self) -> rich.repr.Result:
738+
yield from super().__rich_repr__()
739+
yield "from_app_focus", self.from_app_focus
740+
727741

728742
class Blur(Event, bubble=False):
729743
"""Sent when a widget is blurred (un-focussed).

src/textual/pilot.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,8 @@ async def _post_mouse_events(
442442
# the driver works and emits a click event.
443443
kwargs = message_arguments
444444
if mouse_event_cls is Click:
445-
kwargs["chain"] = chain
445+
kwargs = {**kwargs, "chain": chain}
446+
446447
widget_at, _ = app.get_widget_at(*offset)
447448
event = mouse_event_cls(**kwargs)
448449
# Bypass event processing in App.on_event. Because App.on_event

src/textual/screen.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -869,12 +869,20 @@ def _update_focus_styles(
869869
[widget for widget in widgets if widget._has_focus_within], animate=True
870870
)
871871

872-
def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
872+
def set_focus(
873+
self,
874+
widget: Widget | None,
875+
scroll_visible: bool = True,
876+
from_app_focus: bool = False,
877+
) -> None:
873878
"""Focus (or un-focus) a widget. A focused widget will receive key events first.
874879
875880
Args:
876881
widget: Widget to focus, or None to un-focus.
877882
scroll_visible: Scroll widget in to view.
883+
from_app_focus: True if this focus is due to the app itself having regained
884+
focus. False if the focus is being set because a widget within the app
885+
regained focus.
878886
"""
879887
if widget is self.focused:
880888
# Widget is already focused
@@ -899,7 +907,7 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
899907
# Change focus
900908
self.focused = widget
901909
# Send focus event
902-
widget.post_message(events.Focus())
910+
widget.post_message(events.Focus(from_app_focus=from_app_focus))
903911
focused = widget
904912

905913
if scroll_visible:

src/textual/widgets/_input.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ def _on_blur(self, event: Blur) -> None:
641641

642642
def _on_focus(self, event: Focus) -> None:
643643
self._restart_blink()
644-
if self.select_on_focus:
644+
if self.select_on_focus and not event.from_app_focus:
645645
self.selection = Selection(0, len(self.value))
646646
self.app.cursor_position = self.cursor_screen_offset
647647
self._suggestion = ""
@@ -761,27 +761,33 @@ def action_cursor_left(self, select: bool = False) -> None:
761761
Args:
762762
select: If `True`, select the text to the left of the cursor.
763763
"""
764+
start, end = self.selection
764765
if select:
765-
start, end = self.selection
766766
self.selection = Selection(start, end - 1)
767767
else:
768-
self.cursor_position -= 1
768+
if self.selection.is_empty:
769+
self.cursor_position -= 1
770+
else:
771+
self.cursor_position = min(start, end)
769772

770773
def action_cursor_right(self, select: bool = False) -> None:
771774
"""Accept an auto-completion or move the cursor one position to the right.
772775
773776
Args:
774777
select: If `True`, select the text to the right of the cursor.
775778
"""
779+
start, end = self.selection
776780
if select:
777-
start, end = self.selection
778781
self.selection = Selection(start, end + 1)
779782
else:
780783
if self._cursor_at_end and self._suggestion:
781784
self.value = self._suggestion
782785
self.cursor_position = len(self.value)
783786
else:
784-
self.cursor_position += 1
787+
if self.selection.is_empty:
788+
self.cursor_position += 1
789+
else:
790+
self.cursor_position = max(start, end)
785791

786792
def action_home(self, select: bool = False) -> None:
787793
"""Move the cursor to the start of the input.

src/textual/widgets/_select.py

+91-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from textual.css.query import NoMatches
1414
from textual.message import Message
1515
from textual.reactive import var
16+
from textual.timer import Timer
1617
from textual.widgets import Static
1718
from textual.widgets._option_list import Option, OptionList
1819

@@ -59,6 +60,57 @@ class UpdateSelection(Message):
5960
option_index: int
6061
"""The index of the new selection."""
6162

63+
def __init__(self, type_to_search: bool = True) -> None:
64+
super().__init__()
65+
self._type_to_search = type_to_search
66+
"""If True (default), the user can type to search for a matching option and the cursor will jump to it."""
67+
68+
self._search_query: str = ""
69+
"""The current search query used to find a matching option and jump to it."""
70+
71+
self._search_reset_delay: float = 0.7
72+
"""The number of seconds to wait after the most recent key press before resetting the search query."""
73+
74+
def on_mount(self) -> None:
75+
def reset_query() -> None:
76+
self._search_query = ""
77+
78+
self._search_reset_timer = Timer(
79+
self, self._search_reset_delay, callback=reset_query
80+
)
81+
82+
def watch_has_focus(self, value: bool) -> None:
83+
self._search_query = ""
84+
if value:
85+
self._search_reset_timer._start()
86+
else:
87+
self._search_reset_timer.reset()
88+
self._search_reset_timer.stop()
89+
super().watch_has_focus(value)
90+
91+
async def _on_key(self, event: events.Key) -> None:
92+
if not self._type_to_search:
93+
return
94+
95+
self._search_reset_timer.reset()
96+
97+
if event.character is not None and event.is_printable:
98+
event.time = 0
99+
event.stop()
100+
event.prevent_default()
101+
102+
# Update the search query and jump to the next option that matches.
103+
self._search_query += event.character
104+
index = self._find_search_match(self._search_query)
105+
if index is not None:
106+
self.select(index)
107+
108+
def check_consume_key(self, key: str, character: str | None = None) -> bool:
109+
"""Check if the widget may consume the given key."""
110+
return (
111+
self._type_to_search and character is not None and character.isprintable()
112+
)
113+
62114
def select(self, index: int | None) -> None:
63115
"""Move selection.
64116
@@ -68,6 +120,38 @@ def select(self, index: int | None) -> None:
68120
self.highlighted = index
69121
self.scroll_to_highlight()
70122

123+
def _find_search_match(self, query: str) -> int | None:
124+
"""A simple substring search which favors options containing the substring
125+
earlier in the prompt.
126+
127+
Args:
128+
query: The substring to search for.
129+
130+
Returns:
131+
The index of the option that matches the query, or `None` if no match is found.
132+
"""
133+
best_match: int | None = None
134+
minimum_index: int | None = None
135+
136+
query = query.lower()
137+
for index, option in enumerate(self._options):
138+
prompt = option.prompt
139+
if isinstance(prompt, Text):
140+
lower_prompt = prompt.plain.lower()
141+
elif isinstance(prompt, str):
142+
lower_prompt = prompt.lower()
143+
else:
144+
continue
145+
146+
match_index = lower_prompt.find(query)
147+
if match_index != -1 and (
148+
minimum_index is None or match_index < minimum_index
149+
):
150+
best_match = index
151+
minimum_index = match_index
152+
153+
return best_match
154+
71155
def action_dismiss(self) -> None:
72156
"""Dismiss the overlay."""
73157
self.post_message(self.Dismiss())
@@ -295,6 +379,7 @@ def __init__(
295379
prompt: str = "Select",
296380
allow_blank: bool = True,
297381
value: SelectType | NoSelection = BLANK,
382+
type_to_search: bool = True,
298383
name: str | None = None,
299384
id: str | None = None,
300385
classes: str | None = None,
@@ -313,6 +398,7 @@ def __init__(
313398
value: Initial value selected. Should be one of the values in `options`.
314399
If no initial value is set and `allow_blank` is `False`, the widget
315400
will auto-select the first available option.
401+
type_to_search: If `True`, typing will search for options.
316402
name: The name of the select control.
317403
id: The ID of the control in the DOM.
318404
classes: The CSS classes of the control.
@@ -327,6 +413,7 @@ def __init__(
327413
self.prompt = prompt
328414
self._value = value
329415
self._setup_variables_for_options(options)
416+
self._type_to_search = type_to_search
330417
if tooltip is not None:
331418
self.tooltip = tooltip
332419

@@ -338,6 +425,7 @@ def from_values(
338425
prompt: str = "Select",
339426
allow_blank: bool = True,
340427
value: SelectType | NoSelection = BLANK,
428+
type_to_search: bool = True,
341429
name: str | None = None,
342430
id: str | None = None,
343431
classes: str | None = None,
@@ -357,6 +445,7 @@ def from_values(
357445
value: Initial value selected. Should be one of the values in `values`.
358446
If no initial value is set and `allow_blank` is `False`, the widget
359447
will auto-select the first available value.
448+
type_to_search: If `True`, typing will search for options.
360449
name: The name of the select control.
361450
id: The ID of the control in the DOM.
362451
classes: The CSS classes of the control.
@@ -372,6 +461,7 @@ def from_values(
372461
prompt=prompt,
373462
allow_blank=allow_blank,
374463
value=value,
464+
type_to_search=type_to_search,
375465
name=name,
376466
id=id,
377467
classes=classes,
@@ -496,7 +586,7 @@ def _watch_value(self, value: SelectType | NoSelection) -> None:
496586
def compose(self) -> ComposeResult:
497587
"""Compose Select with overlay and current value."""
498588
yield SelectCurrent(self.prompt)
499-
yield SelectOverlay()
589+
yield SelectOverlay(type_to_search=self._type_to_search)
500590

501591
def _on_mount(self, _event: events.Mount) -> None:
502592
"""Set initial values."""

0 commit comments

Comments
 (0)