Skip to content

Commit 65c1a1c

Browse files
authored
Merge branch 'main' into windows-logging
2 parents bc20ed5 + 176ce63 commit 65c1a1c

File tree

24 files changed

+586
-213
lines changed

24 files changed

+586
-213
lines changed

.github/ISSUE_TEMPLATE/bug_report.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
2-
name: Bug report
3-
about: Create a report to help us improve
2+
name: Report a bug
3+
about: Report a crash or something not working as described in the docs
44
title: ''
55
labels: ''
66
assignees: ''
@@ -11,14 +11,18 @@ Have you checked closed issues? (https://github.com/Textualize/textual/issues?q=
1111

1212
Have you checked against the most recent version of Textual? (https://pypi.org/search/?q=textual)
1313

14-
## Feature requests
14+
## Consider discussions!
1515

16-
Please post feature requests to Ideas. (https://github.com/Textualize/textual/discussions/categories/ideas)
16+
Issues are for actionable items only.
17+
If Textual crashes or behaves differently from the docs, then submit an issue.
18+
If you want to know how to do something, or you have a suggestion for a new feature, then open a discussion (https://github.com/Textualize/textual/discussions/).
1719

20+
For realtime help, join our discord server (https://discord.gg/Enf6Z3qhVr)
1821

1922
## The bug
2023

21-
Please give a brief but clear explanation of the issue. If you can, include a complete working example that demonstrates the bug. **Check it can run without modifications.**
24+
Please give a brief but clear explanation of the issue.
25+
If you can, include a complete working example that demonstrates the bug. **Check it can run without modifications.**
2226

2327
It will be helpful if you run the following command and paste the results:
2428

.github/ISSUE_TEMPLATE/config.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
blank_issues_enabled: false
2+
contact_links:
3+
- name: Get help or request a feature
4+
url: https://github.com/Textualize/textual/discussions
5+
about: Discussions are best for getting feedback
6+
- name: Chat to Textual devs
7+
url : https://discord.gg/Enf6Z3qhVr
8+
about: Join our Discord server for realtime assistance

.github/ISSUE_TEMPLATE/task.md

Lines changed: 0 additions & 10 deletions
This file was deleted.

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,27 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
89
## Unreleased
910

11+
### Added
12+
13+
- Added `compact` parameter to `MaskedInput` https://github.com/Textualize/textual/pull/5952
14+
1015
### Fixed
1116

17+
- Fixed `query_one` and `query_exactly_one` not raising documented `WrongType` exception.
1218
- Fixed logging to a file on Windows https://github.com/Textualize/textual/issues/5941
1319

20+
### Changed
21+
22+
- Breaking change: `Widget.anchor` now has different semantics. It should be applied to a container and anchors to the bottom of the scroll position. https://github.com/Textualize/textual/pull/5950
23+
24+
### Added
25+
26+
- Added `Markdown.append` https://github.com/Textualize/textual/pull/5950
27+
- Added `Widget.release_anchor` https://github.com/Textualize/textual/pull/5950
28+
1429
## [3.7.1] - 2025-07-09
1530

1631
### Fixed

examples/mother.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def compose(self) -> ComposeResult:
7474
def on_mount(self) -> None:
7575
"""You might want to change the model if you don't have access to it."""
7676
self.model = llm.get_model("gpt-4o")
77+
self.query_one("#chat-view").anchor()
7778

7879
@on(Input.Submitted)
7980
async def on_input(self, event: Input.Submitted) -> None:
@@ -82,8 +83,6 @@ async def on_input(self, event: Input.Submitted) -> None:
8283
event.input.clear()
8384
await chat_view.mount(Prompt(event.value))
8485
await chat_view.mount(response := Response())
85-
response.anchor()
86-
8786
self.send_prompt(event.value, response)
8887

8988
@work(thread=True)

src/textual/_compositor.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@
3636
from textual.geometry import NULL_SPACING, Offset, Region, Size, Spacing
3737
from textual.map_geometry import MapGeometry
3838
from textual.strip import Strip, StripRenderable
39+
from textual.widget import Widget
3940

4041
if TYPE_CHECKING:
4142
from typing_extensions import TypeAlias
4243

4344
from textual.screen import Screen
44-
from textual.widget import Widget
4545

4646

4747
class ReflowResult(NamedTuple):
@@ -605,6 +605,18 @@ def add_widget(
605605
# Get the region that will be updated
606606
sub_clip = clip.intersection(child_region)
607607

608+
if widget._anchored and not widget._anchor_released:
609+
scroll_y = widget.scroll_y
610+
new_scroll_y = (
611+
arrange_result.spatial_map.total_region.bottom
612+
- (
613+
widget.container_size.height
614+
- widget.scrollbar_size_horizontal
615+
)
616+
)
617+
widget.set_reactive(Widget.scroll_y, new_scroll_y)
618+
widget.watch_scroll_y(scroll_y, new_scroll_y)
619+
608620
if visible_only:
609621
placements = arrange_result.get_visible_placements(
610622
sub_clip - child_region.offset + widget.scroll_offset

src/textual/_xterm_parser.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
# to be unsuccessful?
1919
_MAX_SEQUENCE_SEARCH_THRESHOLD = 32
2020

21-
_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[-?\d;]+[mM]|M...)\Z")
21+
_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[-\d;]+[mM]|M...)\Z")
2222
_re_terminal_mode_response = re.compile(
2323
"^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\d)\$y"
2424
)
@@ -50,7 +50,7 @@
5050

5151

5252
class XTermParser(Parser[Message]):
53-
_re_sgr_mouse = re.compile(r"\x1b\[<(-?\d+);(-?\d+);(-?\d+)([Mm])")
53+
_re_sgr_mouse = re.compile(r"\x1b\[<(\d+);(-?\d+);(-?\d+)([Mm])")
5454

5555
def __init__(self, debug: bool = False) -> None:
5656
self.last_x = 0.0

src/textual/css/parse.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import dataclasses
4+
import re
45
from functools import lru_cache
56
from typing import Iterable, Iterator, NoReturn
67

@@ -16,7 +17,13 @@
1617
SelectorType,
1718
)
1819
from textual.css.styles import Styles
19-
from textual.css.tokenize import Token, tokenize, tokenize_declarations, tokenize_values
20+
from textual.css.tokenize import (
21+
IDENTIFIER,
22+
Token,
23+
tokenize,
24+
tokenize_declarations,
25+
tokenize_values,
26+
)
2027
from textual.css.tokenizer import ReferencedBy, UnexpectedEnd
2128
from textual.css.types import CSSLocation, Specificity3
2229
from textual.suggestions import get_suggestion
@@ -33,6 +40,21 @@
3340
"nested": (SelectorType.NESTED, (0, 0, 0)),
3441
}
3542

43+
RE_ID_SELECTOR = re.compile("#" + IDENTIFIER)
44+
45+
46+
@lru_cache(maxsize=128)
47+
def is_id_selector(selector: str) -> bool:
48+
"""Is the selector a single ID selector, i.e. "#foo"?
49+
50+
Args:
51+
selector: A CSS selector.
52+
53+
Returns:
54+
`True` if the selector is a simple ID selector, otherwise `False`.
55+
"""
56+
return RE_ID_SELECTOR.fullmatch(selector) is not None
57+
3658

3759
def _add_specificity(
3860
specificity1: Specificity3, specificity2: Specificity3

src/textual/css/query.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ def first(
242242
if expect_type is not None:
243243
if not isinstance(first, expect_type):
244244
raise WrongType(
245-
f"Query value is wrong type; expected {expect_type}, got {type(first)}"
245+
f"Query value is the wrong type; expected type {expect_type.__name__!r}, found {first}"
246246
)
247247
return first
248248
else:
@@ -324,7 +324,7 @@ def last(
324324
last = self.nodes[-1]
325325
if expect_type is not None and not isinstance(last, expect_type):
326326
raise WrongType(
327-
f"Query value is wrong type; expected {expect_type}, got {type(last)}"
327+
f"Query value is the wrong type; expected type {expect_type.__name__!r}, found {last}"
328328
)
329329
return last
330330

src/textual/dom.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,16 @@
4040
from textual.css.constants import VALID_DISPLAY, VALID_VISIBILITY
4141
from textual.css.errors import DeclarationError, StyleValueError
4242
from textual.css.match import match
43-
from textual.css.parse import parse_declarations, parse_selectors
44-
from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches
43+
from textual.css.parse import is_id_selector, parse_declarations, parse_selectors
44+
from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches, WrongType
4545
from textual.css.styles import RenderStyles, Styles
4646
from textual.css.tokenize import IDENTIFIER
4747
from textual.css.tokenizer import TokenError
4848
from textual.message_pump import MessagePump
4949
from textual.reactive import Reactive, ReactiveError, _Mutated, _watch
5050
from textual.style import Style as VisualStyle
5151
from textual.timer import Timer
52-
from textual.walk import walk_breadth_first, walk_depth_first
52+
from textual.walk import walk_breadth_first, walk_breadth_search_id, walk_depth_first
5353
from textual.worker_manager import WorkerManager
5454

5555
if TYPE_CHECKING:
@@ -65,9 +65,6 @@
6565
from textual.widget import Widget
6666
from textual.worker import Worker, WorkType, ResultType
6767

68-
# Unused & ignored imports are needed for the docs to link to these objects:
69-
from textual.css.query import WrongType # type: ignore # noqa: F401
70-
7168
from typing_extensions import Literal
7269

7370
_re_identifier = re.compile(IDENTIFIER)
@@ -1469,6 +1466,24 @@ def query_one(
14691466
else:
14701467
query_selector = selector.__name__
14711468

1469+
if is_id_selector(query_selector):
1470+
cache_key = (base_node._nodes._updates, query_selector, expect_type)
1471+
cached_result = base_node._query_one_cache.get(cache_key)
1472+
if cached_result is not None:
1473+
return cached_result
1474+
if (
1475+
node := walk_breadth_search_id(
1476+
base_node, query_selector[1:], with_root=False
1477+
)
1478+
) is not None:
1479+
if expect_type is not None and not isinstance(node, expect_type):
1480+
raise WrongType(
1481+
f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
1482+
)
1483+
base_node._query_one_cache[cache_key] = node
1484+
return node
1485+
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
1486+
14721487
try:
14731488
selector_set = parse_selectors(query_selector)
14741489
except TokenError:
@@ -1488,12 +1503,14 @@ def query_one(
14881503
if not match(selector_set, node):
14891504
continue
14901505
if expect_type is not None and not isinstance(node, expect_type):
1491-
continue
1506+
raise WrongType(
1507+
f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
1508+
)
14921509
if cache_key is not None:
14931510
base_node._query_one_cache[cache_key] = node
14941511
return node
14951512

1496-
raise NoMatches(f"No nodes match {selector!r} on {self!r}")
1513+
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
14971514

14981515
if TYPE_CHECKING:
14991516

@@ -1561,19 +1578,19 @@ def query_exactly_one(
15611578
if not match(selector_set, node):
15621579
continue
15631580
if expect_type is not None and not isinstance(node, expect_type):
1564-
continue
1581+
raise WrongType(
1582+
f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
1583+
)
15651584
for later_node in iter_children:
15661585
if match(selector_set, later_node):
1567-
if expect_type is not None and not isinstance(node, expect_type):
1568-
continue
15691586
raise TooManyMatches(
15701587
"Call to query_one resulted in more than one matched node"
15711588
)
15721589
if cache_key is not None:
15731590
base_node._query_one_cache[cache_key] = node
15741591
return node
15751592

1576-
raise NoMatches(f"No nodes match {selector!r} on {self!r}")
1593+
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
15771594

15781595
if TYPE_CHECKING:
15791596

0 commit comments

Comments
 (0)