Skip to content

Commit 112e8c6

Browse files
authored
Merge pull request #4953 from Textualize/screenshot-deliver
added name to deliver
2 parents cd8bbb3 + 914ec2a commit 112e8c6

File tree

6 files changed

+184
-113
lines changed

6 files changed

+184
-113
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ repos:
2121
hooks:
2222
- id: isort
2323
name: isort (python)
24-
language_version: '3.8'
24+
language_version: '3.11'
2525
args: ['--profile', 'black', '--filter-files']
2626
- repo: https://github.com/psf/black
2727
rev: '24.1.1'
@@ -31,6 +31,6 @@ repos:
3131
rev: v2.3.0
3232
hooks:
3333
- id: pycln
34-
language_version: '3.8'
34+
language_version: '3.11'
3535
args: [--all]
3636
exclude: ^tests/snapshot_tests

src/textual/app.py

Lines changed: 77 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,27 +1027,11 @@ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
10271027
"Maximize", "Maximize the focused widget", screen.action_maximize
10281028
)
10291029

1030-
# Don't save screenshot for web drivers until we have the deliver_file in place
1031-
if self._driver.__class__.__name__ in {"LinuxDriver", "WindowsDriver"}:
1032-
1033-
def export_screenshot() -> None:
1034-
"""Export a screenshot and write a notification."""
1035-
filename = self.save_screenshot()
1036-
try:
1037-
self.notify(f"Saved {filename}", title="Screenshot")
1038-
except Exception as error:
1039-
self.log.error(error)
1040-
self.notify(
1041-
"Failed to save screenshot.",
1042-
title="Screenshot",
1043-
severity="warning",
1044-
)
1045-
1046-
yield SystemCommand(
1047-
"Save screenshot",
1048-
"Save an SVG 'screenshot' of the current screen (in the current working directory)",
1049-
export_screenshot,
1050-
)
1030+
yield SystemCommand(
1031+
"Save screenshot",
1032+
"Save an SVG 'screenshot' of the current screen",
1033+
self.deliver_screenshot,
1034+
)
10511035

10521036
def get_default_screen(self) -> Screen:
10531037
"""Get the default screen.
@@ -1385,14 +1369,16 @@ def action_toggle_dark(self) -> None:
13851369
"""An [action](/guide/actions) to toggle dark mode."""
13861370
self.dark = not self.dark
13871371

1388-
def action_screenshot(self, filename: str | None = None, path: str = "./") -> None:
1372+
def action_screenshot(
1373+
self, filename: str | None = None, path: str | None = None
1374+
) -> None:
13891375
"""This [action](/guide/actions) will save an SVG file containing the current contents of the screen.
13901376
13911377
Args:
13921378
filename: Filename of screenshot, or None to auto-generate.
1393-
path: Path to directory. Defaults to current working directory.
1379+
path: Path to directory. Defaults to the user's Downloads directory.
13941380
"""
1395-
self.save_screenshot(filename, path)
1381+
self.deliver_screenshot(filename, path)
13961382

13971383
def export_screenshot(self, *, title: str | None = None) -> str:
13981384
"""Export an SVG screenshot of the current screen.
@@ -1451,6 +1437,42 @@ def save_screenshot(
14511437
svg_file.write(screenshot_svg)
14521438
return svg_path
14531439

1440+
def deliver_screenshot(
1441+
self,
1442+
filename: str | None = None,
1443+
path: str | None = None,
1444+
time_format: str | None = None,
1445+
) -> str | None:
1446+
"""Deliver a screenshot of the app.
1447+
1448+
This with save the screenshot when running locally, or serve it when the app
1449+
is running in a web browser.
1450+
1451+
Args:
1452+
filename: Filename of SVG screenshot, or None to auto-generate
1453+
a filename with the date and time.
1454+
path: Path to directory for output when saving locally (not used when app is running in the browser).
1455+
Defaults to current working directory.
1456+
time_format: Date and time format to use if filename is None.
1457+
Defaults to a format like ISO 8601 with some reserved characters replaced with underscores.
1458+
1459+
Returns:
1460+
The delivery key that uniquely identifies the file delivery.
1461+
"""
1462+
if not filename:
1463+
svg_filename = generate_datetime_filename(self.title, ".svg", time_format)
1464+
else:
1465+
svg_filename = filename
1466+
screenshot_svg = self.export_screenshot()
1467+
return self.deliver_text(
1468+
io.StringIO(screenshot_svg),
1469+
save_directory=path,
1470+
save_filename=svg_filename,
1471+
open_method="browser",
1472+
mime_type="image/svg+xml",
1473+
name="screenshot",
1474+
)
1475+
14541476
def bind(
14551477
self,
14561478
keys: str,
@@ -3926,6 +3948,7 @@ def deliver_text(
39263948
open_method: Literal["browser", "download"] = "download",
39273949
encoding: str | None = None,
39283950
mime_type: str | None = None,
3951+
name: str | None = None,
39293952
) -> str | None:
39303953
"""Deliver a text file to the end-user of the application.
39313954
@@ -3956,6 +3979,8 @@ def deliver_text(
39563979
mime_type: The MIME type of the file or None to guess based on file extension.
39573980
If no MIME type is supplied and we cannot guess the MIME type, from the
39583981
file extension, the MIME type will be set to "text/plain".
3982+
name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete]
3983+
and [`DeliveryComplete`][textual.events.DeliveryComplete].
39593984
39603985
Returns:
39613986
The delivery key that uniquely identifies the file delivery.
@@ -3985,6 +4010,7 @@ def deliver_text(
39854010
open_method=open_method,
39864011
encoding=encoding,
39874012
mime_type=mime_type,
4013+
name=name,
39884014
)
39894015

39904016
def deliver_binary(
@@ -3995,6 +4021,7 @@ def deliver_binary(
39954021
save_filename: str | None = None,
39964022
open_method: Literal["browser", "download"] = "download",
39974023
mime_type: str | None = None,
4024+
name: str | None = None,
39984025
) -> str | None:
39994026
"""Deliver a binary file to the end-user of the application.
40004027
@@ -4033,6 +4060,8 @@ def deliver_binary(
40334060
mime_type: The MIME type of the file or None to guess based on file extension.
40344061
If no MIME type is supplied and we cannot guess the MIME type, from the
40354062
file extension, the MIME type will be set to "application/octet-stream".
4063+
name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete]
4064+
and [`DeliveryComplete`][textual.events.DeliveryComplete].
40364065
40374066
Returns:
40384067
The delivery key that uniquely identifies the file delivery.
@@ -4061,6 +4090,7 @@ def deliver_binary(
40614090
open_method=open_method,
40624091
mime_type=mime_type,
40634092
encoding=None,
4093+
name=name,
40644094
)
40654095

40664096
def _deliver_binary(
@@ -4072,10 +4102,11 @@ def _deliver_binary(
40724102
open_method: Literal["browser", "download"],
40734103
encoding: str | None = None,
40744104
mime_type: str | None = None,
4105+
name: str | None = None,
40754106
) -> str | None:
40764107
"""Deliver a binary file to the end-user of the application."""
40774108
if self._driver is None:
4078-
return
4109+
return None
40794110

40804111
# Generate a filename if the file-like object doesn't have one.
40814112
if save_filename is None:
@@ -4099,6 +4130,27 @@ def _deliver_binary(
40994130
encoding=encoding,
41004131
open_method=open_method,
41014132
mime_type=mime_type,
4133+
name=name,
41024134
)
41034135

41044136
return delivery_key
4137+
4138+
@on(events.DeliveryComplete)
4139+
def _on_delivery_complete(self, event: events.DeliveryComplete) -> None:
4140+
"""Handle a successfully delivered screenshot."""
4141+
if event.name == "screenshot":
4142+
if event.path is None:
4143+
self.notify("Saved screenshot", title="Screenshot")
4144+
else:
4145+
self.notify(
4146+
f"Saved screenshot to [green]{str(event.path)!r}",
4147+
title="Screenshot",
4148+
)
4149+
4150+
@on(events.DeliveryFailed)
4151+
def _on_delivery_failed(self, event: events.DeliveryComplete) -> None:
4152+
"""Handle a failure to deliver the screenshot."""
4153+
if event.name == "screenshot":
4154+
self.notify(
4155+
"Failed to save screenshot", title="Screenshot", severity="error"
4156+
)

src/textual/driver.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ def deliver_binary(
208208
open_method: Literal["browser", "download"] = "download",
209209
encoding: str | None = None,
210210
mime_type: str | None = None,
211+
name: str | None = None,
211212
) -> None:
212213
"""Save the file `path_or_file` to `save_path`.
213214
@@ -227,6 +228,9 @@ def deliver_binary(
227228
in the `Content-Type` header.
228229
mime_type: *web only* The MIME type of the file. This will be used to
229230
set the `Content-Type` header in the HTTP response.
231+
name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete]
232+
and [`DeliveryComplete`][textual.events.DeliveryComplete].
233+
230234
"""
231235

232236
def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None:
@@ -239,7 +243,9 @@ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None:
239243
data = read(chunk_size)
240244
if not data:
241245
# No data left to read - delivery is complete.
242-
self._delivery_complete(delivery_key, save_path)
246+
self._delivery_complete(
247+
delivery_key, save_path=save_path, name=name
248+
)
243249
break
244250
write(data)
245251
except Exception as error:
@@ -249,7 +255,7 @@ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None:
249255
import traceback
250256

251257
log.error(str(traceback.format_exc()))
252-
self._delivery_failed(delivery_key, exception=error)
258+
self._delivery_failed(delivery_key, exception=error, name=name)
253259
finally:
254260
if not binary.closed:
255261
binary.close()
@@ -262,22 +268,26 @@ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None:
262268
thread = threading.Thread(target=save_file_thread, args=(binary, mode))
263269
thread.start()
264270

265-
def _delivery_complete(self, delivery_key: str, save_path: Path | None) -> None:
271+
def _delivery_complete(
272+
self, delivery_key: str, save_path: Path | None, name: str | None
273+
) -> None:
266274
"""Called when a file has been delivered successfully.
267275
268276
Delivers a DeliveryComplete event to the app.
269277
"""
270278
self._app.call_from_thread(
271279
self._app.post_message,
272-
events.DeliveryComplete(key=delivery_key, path=save_path),
280+
events.DeliveryComplete(key=delivery_key, path=save_path, name=name),
273281
)
274282

275-
def _delivery_failed(self, delivery_key: str, exception: BaseException) -> None:
283+
def _delivery_failed(
284+
self, delivery_key: str, exception: BaseException, name: str | None
285+
) -> None:
276286
"""Called when a file delivery fails.
277287
278288
Delivers a DeliveryFailed event to the app.
279289
"""
280290
self._app.call_from_thread(
281291
self._app.post_message,
282-
events.DeliveryFailed(key=delivery_key, exception=exception),
292+
events.DeliveryFailed(key=delivery_key, exception=exception, name=name),
283293
)

src/textual/drivers/web_driver.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None:
259259
)
260260
else:
261261
# Read the requested amount of data from the file
262+
name = payload.get("name", None)
262263
try:
263264
log.debug(f"Reading {requested_size} bytes from {delivery_key}")
264265
chunk = file_like.read(requested_size)
@@ -269,7 +270,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None:
269270
log.info(f"Delivery complete for {delivery_key}")
270271
file_like.close()
271272
del deliveries[delivery_key]
272-
self._delivery_complete(delivery_key, save_path=None)
273+
self._delivery_complete(delivery_key, save_path=None, name=name)
273274
except Exception as error:
274275
file_like.close()
275276
del deliveries[delivery_key]
@@ -282,7 +283,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None:
282283

283284
log.error(str(traceback.format_exc()))
284285

285-
self._delivery_failed(delivery_key, exception=error)
286+
self._delivery_failed(delivery_key, exception=error, name=name)
286287

287288
def open_url(self, url: str, new_tab: bool = True) -> None:
288289
"""Open a URL in the default web browser.
@@ -321,6 +322,7 @@ def _deliver_file(
321322
open_method: Literal["browser", "download"],
322323
encoding: str | None = None,
323324
mime_type: str | None = None,
325+
name: str | None = None,
324326
) -> None:
325327
"""Deliver a file to the end-user of the application."""
326328
binary.seek(0)
@@ -335,6 +337,7 @@ def _deliver_file(
335337
"open_method": open_method,
336338
"encoding": encoding or "",
337339
"mime_type": mime_type or "",
340+
"name": name,
338341
}
339342
self.write_meta(meta)
340343
log.info(f"Delivering file {meta['path']!r}: {meta!r}")

src/textual/events.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,9 @@ class DeliveryComplete(Event, bubble=False):
750750
example if the file was delivered via web browser.
751751
"""
752752

753+
name: str | None = None
754+
"""Optional name returned to the app to identify the download."""
755+
753756

754757
@dataclass
755758
class DeliveryFailed(Event, bubble=False):
@@ -760,3 +763,6 @@ class DeliveryFailed(Event, bubble=False):
760763

761764
exception: BaseException
762765
"""The exception that was raised during the delivery."""
766+
767+
name: str | None = None
768+
"""Optional name returned to the app to identify the download."""

0 commit comments

Comments
 (0)