Skip to content

Commit dded0ec

Browse files
Merge branch 'main' into add-widget-lock
2 parents 8400a68 + 2b3c71c commit dded0ec

37 files changed

+2934
-885
lines changed

.github/workflows/pythonpackage.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ on:
1111
- "**.lock"
1212
- "Makefile"
1313

14+
env:
15+
PYTEST_ADDOPTS: "--color=yes"
16+
1417
jobs:
1518
build:
1619
runs-on: ${{ matrix.os }}

.pre-commit-config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,20 @@ repos:
1717
- id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline
1818
- id: mixed-line-ending # replaces or checks mixed line ending
1919
- repo: https://github.com/pycqa/isort
20-
rev: 5.12.0
20+
rev: '5.12.0'
2121
hooks:
2222
- id: isort
2323
name: isort (python)
2424
language_version: '3.11'
25-
args: ["--profile", "black", "--filter-files"]
25+
args: ['--profile', 'black', '--filter-files']
2626
- repo: https://github.com/psf/black
27-
rev: 24.1.1
27+
rev: '24.1.1'
2828
hooks:
2929
- id: black
3030
- repo: https://github.com/hadialqattan/pycln # removes unused imports
3131
rev: v2.3.0
3232
hooks:
3333
- id: pycln
34-
language_version: "3.11"
34+
language_version: '3.11'
3535
args: [--all]
3636
exclude: ^tests/snapshot_tests

CHANGELOG.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,48 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1111

1212
- Added an `asyncio` lock attribute `Widget.lock` to be used to synchronize widget state https://github.com/Textualize/textual/issues/4134
1313

14-
## [0.49.1] - 2023-02-08
14+
## [0.51.0] - 2024-02-15
15+
16+
### Added
17+
18+
- TextArea now has `read_only` mode https://github.com/Textualize/textual/pull/4151
19+
- Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149
20+
- Add undo and redo to TextArea https://github.com/Textualize/textual/pull/4124
21+
- Added support for command palette command discoverability https://github.com/Textualize/textual/pull/4154
22+
23+
### Fixed
24+
25+
- Fixed out-of-view `Tab` not being scrolled into view when `Tabs.active` is assigned https://github.com/Textualize/textual/issues/4150
26+
- Fixed `TabbedContent.TabActivate` not being posted when `TabbedContent.active` is assigned https://github.com/Textualize/textual/issues/4150
27+
28+
### Changed
29+
30+
- Breaking change: Renamed `TextArea.tab_behaviour` to `TextArea.tab_behavior` https://github.com/Textualize/textual/pull/4124
31+
- `TextArea.theme` now defaults to `"css"` instead of None, and is no longer optional https://github.com/Textualize/textual/pull/4157
32+
33+
### Fixed
34+
35+
- Improve support for selector lists in nested TCSS https://github.com/Textualize/textual/issues/3969
36+
- Improve support for rule declarations after nested TCSS rule sets https://github.com/Textualize/textual/issues/3999
37+
38+
## [0.50.1] - 2024-02-09
39+
40+
### Fixed
41+
42+
- Fixed tint applied to ANSI colors https://github.com/Textualize/textual/pull/4142
43+
44+
## [0.50.0] - 2024-02-08
1545

1646
### Fixed
1747

1848
- Fixed issue with ANSI colors not being converted to truecolor https://github.com/Textualize/textual/pull/4138
1949
- Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030
2050
- Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878
2151

52+
### Added
53+
54+
- Added support for configuring dark and light themes for code in `Markdown` https://github.com/Textualize/textual/issues/3997
55+
2256
## [0.49.0] - 2024-02-07
2357

2458
### Fixed
@@ -1678,6 +1712,9 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
16781712
- New handler system for messages that doesn't require inheritance
16791713
- Improved traceback handling
16801714

1715+
[0.51.0]: https://github.com/Textualize/textual/compare/v0.50.1...v0.51.0
1716+
[0.50.1]: https://github.com/Textualize/textual/compare/v0.50.0...v0.50.1
1717+
[0.50.0]: https://github.com/Textualize/textual/compare/v0.49.0...v0.50.0
16811718
[0.49.1]: https://github.com/Textualize/textual/compare/v0.49.0...v0.49.1
16821719
[0.49.0]: https://github.com/Textualize/textual/compare/v0.48.2...v0.49.0
16831720
[0.48.2]: https://github.com/Textualize/textual/compare/v0.48.1...v0.48.2
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
---
2+
draft: false
3+
date: 2024-02-11
4+
categories:
5+
- DevLog
6+
authors:
7+
- willmcgugan
8+
---
9+
10+
# File magic with the Python standard library
11+
12+
I recently published [Toolong](https://github.com/textualize/toolong), an app for viewing log files.
13+
There were some interesting technical challenges in building Toolong that I'd like to cover in this post.
14+
15+
<!-- more -->
16+
17+
!!! note "Python is awesome"
18+
19+
This isn't specifically [Textual](https://github.com/textualize/textual/) related. These techniques could be employed in any Python project.
20+
21+
These techniques aren't difficult, and shouldn't be beyond anyone with an intermediate understanding of Python.
22+
They are the kind of "if you know it you know it" knowledge that you may not need often, but can make a massive difference when you do!
23+
24+
## Opening large files
25+
26+
If you were to open a very large text file (multiple gigabyte in size) in an editor, you will almost certainly find that it takes a while. You may also find that it doesn't load at all because you don't have enough memory, or it disables features like syntax highlighting.
27+
28+
This is because most app will do something analogous to this:
29+
30+
```python
31+
with open("access.log", "rb") as log_file:
32+
log_data = log_file.read()
33+
```
34+
35+
All the data is read in to memory, where it can be easily processed.
36+
This is fine for most files of a reasonable size, but when you get in to the gigabyte territory the read and any additional processing will start to use a significant amount of time and memory.
37+
38+
Yet Toolong can open a file of *any* size in a second or so, with syntax highlighting.
39+
It can do this because it doesn't need to read the entire log file in to memory.
40+
Toolong opens a file and reads only the portion of it required to display whatever is on screen at that moment.
41+
When you scroll around the log file, Toolong reads the data off disk as required -- fast enough that you may never even notice it.
42+
43+
### Scanning lines
44+
45+
There is an additional bit of work that Toolong has to do up front in order to show the file.
46+
If you open a large file you may see a progress bar and a message about "scanning".
47+
48+
Toolong needs to know where every line starts and ends in a log file, so it can display a scrollbar bar and allow the user to navigate lines in the file.
49+
In other words it needs to know the offset of every new line (`\n`) character within the file.
50+
51+
This isn't a hard problem in itself.
52+
You might have imagined a loop that reads a chunk at a time and searches for new lines characters.
53+
And that would likely have worked just fine, but there is a bit of magic in the Python standard library that can speed that up.
54+
55+
The [mmap](https://docs.python.org/3/library/mmap.html) module is a real gem for this kind of thing.
56+
A *memory mapped file* is an OS-level construct that *appears* to load a file instantaneously.
57+
In Python you get an object which behaves like a `bytearray`, but loads data from disk when it is accessed.
58+
The beauty of this module is that you can work with files in much the same way as if you had read the entire file in to memory, while leaving the actual reading of the file to the OS.
59+
60+
Here's the method that Toolong uses to scan for line breaks.
61+
Forgive the micro-optimizations, I was going for raw execution speed here.
62+
63+
```python
64+
def scan_line_breaks(
65+
self, batch_time: float = 0.25
66+
) -> Iterable[tuple[int, list[int]]]:
67+
"""Scan the file for line breaks.
68+
69+
Args:
70+
batch_time: Time to group the batches.
71+
72+
Returns:
73+
An iterable of tuples, containing the scan position and a list of offsets of new lines.
74+
"""
75+
fileno = self.fileno
76+
size = self.size
77+
if not size:
78+
return
79+
log_mmap = mmap.mmap(fileno, size, prot=mmap.PROT_READ)
80+
rfind = log_mmap.rfind
81+
position = size
82+
batch: list[int] = []
83+
append = batch.append
84+
get_length = batch.__len__
85+
monotonic = time.monotonic
86+
break_time = monotonic()
87+
88+
while (position := rfind(b"\n", 0, position)) != -1:
89+
append(position)
90+
if get_length() % 1000 == 0 and monotonic() - break_time > batch_time:
91+
break_time = monotonic()
92+
yield (position, batch)
93+
batch = []
94+
append = batch.append
95+
yield (0, batch)
96+
log_mmap.close()
97+
```
98+
99+
This code runs in a thread (actually a [worker](https://textual.textualize.io/guide/workers/)), and will generate line breaks in batches. Without batching, it risks slowing down the UI with millions of rapid events.
100+
101+
It's fast because most of the work is done in `rfind`, which runs at C speed, while the OS reads from the disk.
102+
103+
## Watching a file for changes
104+
105+
Toolong can tail files in realtime.
106+
When something appends to the file, it will be read and displayed virtually instantly.
107+
How is this done?
108+
109+
You can easily *poll* a file for changes, by periodically querying the size or timestamp of a file until it changes.
110+
The downside of this is that you don't get notified immediately if a file changes between polls.
111+
You could poll at a very fast rate, but if you were to do that you would end up burning a lot of CPU for no good reason.
112+
113+
There is a very good solution for this in the standard library.
114+
The [selectors](https://docs.python.org/3/library/selectors.html) module is typically used for working with sockets (network data), but can also work with files (at least on macOS and Linux).
115+
116+
!!! info "Software developers are an unimaginative bunch when it comes to naming things"
117+
118+
Not to be confused with CSS [selectors](https://textual.textualize.io/guide/CSS/#selectors)!
119+
120+
The selectors module can tell you precisely when a file can be read.
121+
It can do this very efficiently, because it relies on the OS to tell us when a file can be read, and doesn't need to poll.
122+
123+
You register a file with a `Selector` object, then call `select()` which returns as soon as there is new data available for reading.
124+
125+
See [watcher.py](https://github.com/Textualize/toolong/blob/main/src/toolong/watcher.py) in Toolong, which runs a thread to monitors files for changes with a selector.
126+
127+
## Textual learnings
128+
129+
This project was a chance for me to "dogfood" Textual.
130+
Other Textual devs have build some cool projects ([Trogon](https://github.com/Textualize/trogon) and [Frogmouth](https://github.com/Textualize/frogmouth)), but before Toolong I had only ever written example apps for docs.
131+
132+
I paid particular attention to Textual error messages when working on Toolong, and improved many of them in Textual.
133+
Much of what I improved were general programming errors, and not Textual errors per se.
134+
For instance, if you forget to call `super()` on a widget constructor, Textual used to give a fairly cryptic error.
135+
It's a fairly common gotcha, even for experience devs, but now Textual will detect that and tell you how to fix it.
136+
137+
There's a lot of other improvements which I thought about when working on this app.
138+
Mostly quality of life features that will make implementing some features more intuitive.
139+
Keep an eye out for those in the next few weeks.
140+
141+
## Found this interesting?
142+
143+
If you would like to talk about this post or anything Textual related, join us on the [Discord server](https://discord.gg/Enf6Z3qhVr).

docs/guide/command_palette.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ The following example will display a blank screen initially, but if you bring up
6868
5. Highlights matching letters in the search.
6969
6. Adds our custom command provider and the default command provider.
7070

71-
There are three methods you can override in a command provider: [`startup`][textual.command.Provider.startup], [`search`][textual.command.Provider.search], and [`shutdown`][textual.command.Provider.shutdown].
71+
There are four methods you can override in a command provider: [`startup`][textual.command.Provider.startup], [`search`][textual.command.Provider.search], [`discover`][textual.command.Provider.discover] and [`shutdown`][textual.command.Provider.shutdown].
7272
All of these methods should be coroutines (`async def`). Only `search` is required, the other methods are optional.
7373
Let's explore those methods in detail.
7474

@@ -99,7 +99,25 @@ In the example above, the callback is a lambda which calls the `open_file` metho
9999
This is a deliberate design decision taken to prevent a single broken `Provider` class from making the command palette unusable.
100100
Errors in command providers will be logged to the [console](./devtools.md).
101101

102-
### Shutdown method
102+
### discover method
103+
104+
The [`discover`][textual.command.Provider.discover] method is responsible for providing results (or *discovery hits*) that should be shown to the user when the command palette input is empty;
105+
this is to aid in command discoverability.
106+
107+
!!! note
108+
109+
Because `discover` hits are shown the moment the command palette is opened, these should ideally be quick to generate;
110+
commands that might take time to generate are best left for `search` -- use `discover` to help the user easily find the most important commands.
111+
112+
`discover` is similar to `search` but with these differences:
113+
114+
- `discover` accepts no parameters (instead of the search value)
115+
- `discover` yields instances of [`DiscoveryHit`][textual.command.DiscoveryHit] (instead of instances of [`Hit`][textual.command.Hit])
116+
- discovery hits are sorted in ascending alphabetical order because there is no matching and no match score is generated
117+
118+
Instances of [`DiscoveryHit`][textual.command.DiscoveryHit] contain information about how the hit should be displayed, an optional help string, and a callback which will be run if the user selects that command.
119+
120+
### shutdown method
103121

104122
The [`shutdown`][textual.command.Provider.shutdown] method is called when the command palette is closed.
105123
You can use this as a hook to gracefully close any objects you created in [`startup`][textual.command.Provider.startup].

docs/guide/events.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,12 @@ In the following example we have three buttons, each of which does something dif
210210

211211
1. The message handler is called when any button is pressed
212212

213+
=== "on_decorator.tcss"
214+
215+
```python title="on_decorator.tcss"
216+
--8<-- "docs/examples/events/on_decorator.tcss"
217+
```
218+
213219
=== "Output"
214220

215221
```{.textual path="docs/examples/events/on_decorator01.py"}
@@ -233,6 +239,12 @@ The following example uses the decorator approach to write individual message ha
233239
2. Matches the button with class names "toggle" *and* "dark"
234240
3. Matches the button with an id of "quit"
235241

242+
=== "on_decorator.tcss"
243+
244+
```python title="on_decorator.tcss"
245+
--8<-- "docs/examples/events/on_decorator.tcss"
246+
```
247+
236248
=== "Output"
237249

238250
```{.textual path="docs/examples/events/on_decorator02.py"}

0 commit comments

Comments
 (0)