Skip to content

Commit 13ec4c3

Browse files
authored
Open url (#4819)
* Local open url * Open URL via driver * Writing meta to open url * Some docstrings and typing * Update docstring * Update docstring in app.py * CHANGELOG * Allow opening URL in a new tab * No errors from App.open_url * Keyword only new_tab argument in App.open_url
1 parent 20ae636 commit 13ec4c3

File tree

4 files changed

+49
-9
lines changed

4 files changed

+49
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
99

1010
### Added
1111

12+
- Added `App.open_url` to open URLs in the web browser. When running via the WebDriver, the URL will be opened in the browser that is controlling the app https://github.com/Textualize/textual/pull/4819
1213
- Added `Widget.is_mouse_over` https://github.com/Textualize/textual/pull/4818
1314
- Added `node` attribute to `events.Enter` and `events.Leave` https://github.com/Textualize/textual/pull/4818
1415

src/textual/app.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3679,3 +3679,13 @@ def action_suspend_process(self) -> None:
36793679
# NOTE: There is no call to publish the resume signal here, this
36803680
# will be handled by the driver posting a SignalResume event
36813681
# (see the event handler on App._resume_signal) above.
3682+
3683+
def open_url(self, url: str, *, new_tab: bool = True) -> None:
3684+
"""Open a URL in the default web browser.
3685+
3686+
Args:
3687+
url: The URL to open.
3688+
new_tab: Whether to open the URL in a new tab.
3689+
"""
3690+
if self._driver is not None:
3691+
self._driver.open_url(url, new_tab)

src/textual/driver.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import asyncio
44
from abc import ABC, abstractmethod
55
from contextlib import contextmanager
6-
from typing import TYPE_CHECKING, Iterator
6+
from typing import TYPE_CHECKING, Any, Iterator
77

88
from . import events
99
from .events import MouseUp
@@ -17,7 +17,7 @@ class Driver(ABC):
1717

1818
def __init__(
1919
self,
20-
app: App,
20+
app: App[Any],
2121
*,
2222
debug: bool = False,
2323
mouse: bool = True,
@@ -146,6 +146,19 @@ def disable_input(self) -> None:
146146
def stop_application_mode(self) -> None:
147147
"""Stop application mode, restore state."""
148148

149+
def open_url(self, url: str, new_tab: bool = True) -> None:
150+
"""Open a URL in the default web browser.
151+
152+
Args:
153+
url: The URL to open.
154+
new_tab: Whether to open the URL in a new tab.
155+
This is only relevant when running via the WebDriver,
156+
and is ignored when called while running through the terminal.
157+
"""
158+
import webbrowser
159+
160+
webbrowser.open(url)
161+
149162
def suspend_application_mode(self) -> None:
150163
"""Suspend application mode.
151164

src/textual/drivers/web_driver.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from codecs import getincrementaldecoder
2020
from functools import partial
2121
from threading import Event, Thread
22+
from typing import Any
2223

2324
from .. import events, log, messages
2425
from .._xterm_parser import XTermParser
@@ -40,7 +41,7 @@ class WebDriver(Driver):
4041

4142
def __init__(
4243
self,
43-
app: App,
44+
app: App[Any],
4445
*,
4546
debug: bool = False,
4647
mouse: bool = True,
@@ -63,7 +64,8 @@ def __init__(
6364
self._input_reader = InputReader()
6465

6566
def write(self, data: str) -> None:
66-
"""Write data to the output device.
67+
"""Write string data to the output device, which may be piped to
68+
the controlling process (i.e. textual-web).
6769
6870
Args:
6971
data: Raw data.
@@ -73,7 +75,8 @@ def write(self, data: str) -> None:
7375
self._write(b"D%s%s" % (len(data_bytes).to_bytes(4, "big"), data_bytes))
7476

7577
def write_meta(self, data: dict[str, object]) -> None:
76-
"""Write meta to the controlling process (i.e. textual-web)
78+
"""Write a dictionary containing some metadata to stdout, which
79+
may be piped to the controlling process (i.e. textual-web).
7780
7881
Args:
7982
data: Meta dict.
@@ -192,13 +195,17 @@ def _on_meta(self, packet_type: str, payload: bytes) -> None:
192195
packet_type: Packet type (currently always "M")
193196
payload: Meta payload (JSON encoded as bytes).
194197
"""
195-
payload_map = json.loads(payload)
198+
payload_map: dict[str, object] = json.loads(payload)
196199
_type = payload_map.get("type", {})
197-
if isinstance(payload_map, dict):
200+
if isinstance(_type, str):
198201
self.on_meta(_type, payload_map)
202+
else:
203+
log.error(
204+
f"Protocol error: type field value is not a string. Value is {_type!r}"
205+
)
199206

200-
def on_meta(self, packet_type: str, payload: dict) -> None:
201-
"""Process meta information.
207+
def on_meta(self, packet_type: str, payload: dict[str, object]) -> None:
208+
"""Process a dictionary containing information received from the controlling process.
202209
203210
Args:
204211
packet_type: The type of the packet.
@@ -216,3 +223,12 @@ def on_meta(self, packet_type: str, payload: dict) -> None:
216223
self._app.post_message(messages.ExitApp())
217224
elif packet_type == "exit":
218225
raise _ExitInput()
226+
227+
def open_url(self, url: str, new_tab: bool = True) -> None:
228+
"""Open a URL in the default web browser.
229+
230+
Args:
231+
url: The URL to open.
232+
new_tab: Whether to open the URL in a new tab.
233+
"""
234+
self.write_meta({"type": "open_url", "url": url, "new_tab": new_tab})

0 commit comments

Comments
 (0)