diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a7ee5979d..75498cddfa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- Added `Document.start` and `end` location properties for convenience https://github.com/Textualize/textual/pull/4267
+- Added `inline` parameter to `run` and `run_async` to run app inline (under the prompt). https://github.com/Textualize/textual/pull/4343
+- Added `mouse` parameter to disable mouse support https://github.com/Textualize/textual/pull/4343
## [0.54.0] - 2024-03-26
diff --git a/docs/examples/how-to/inline01.py b/docs/examples/how-to/inline01.py
new file mode 100644
index 0000000000..65bd22f303
--- /dev/null
+++ b/docs/examples/how-to/inline01.py
@@ -0,0 +1,31 @@
+from datetime import datetime
+
+from textual.app import App, ComposeResult
+from textual.widgets import Digits
+
+
+class ClockApp(App):
+ CSS = """
+ Screen {
+ align: center middle;
+ }
+ #clock {
+ width: auto;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Digits("", id="clock")
+
+ def on_ready(self) -> None:
+ self.update_clock()
+ self.set_interval(1, self.update_clock)
+
+ def update_clock(self) -> None:
+ clock = datetime.now().time()
+ self.query_one(Digits).update(f"{clock:%T}")
+
+
+if __name__ == "__main__":
+ app = ClockApp()
+ app.run(inline=True) # (1)!
diff --git a/docs/examples/how-to/inline02.py b/docs/examples/how-to/inline02.py
new file mode 100644
index 0000000000..05b76e626a
--- /dev/null
+++ b/docs/examples/how-to/inline02.py
@@ -0,0 +1,38 @@
+from datetime import datetime
+
+from textual.app import App, ComposeResult
+from textual.widgets import Digits
+
+
+class ClockApp(App):
+ CSS = """
+ Screen {
+ align: center middle;
+ &:inline {
+ border: none;
+ height: 50vh;
+ Digits {
+ color: $success;
+ }
+ }
+ }
+ #clock {
+ width: auto;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Digits("", id="clock")
+
+ def on_ready(self) -> None:
+ self.update_clock()
+ self.set_interval(1, self.update_clock)
+
+ def update_clock(self) -> None:
+ clock = datetime.now().time()
+ self.query_one(Digits).update(f"{clock:%T}")
+
+
+if __name__ == "__main__":
+ app = ClockApp()
+ app.run(inline=True)
diff --git a/docs/examples/widgets/clock.py b/docs/examples/widgets/clock.py
index 6b18af3c85..10fb281d99 100644
--- a/docs/examples/widgets/clock.py
+++ b/docs/examples/widgets/clock.py
@@ -28,4 +28,4 @@ def update_clock(self) -> None:
if __name__ == "__main__":
app = ClockApp()
- app.run()
+ app.run(inline=True)
diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md
index 5eccb2391a..daff1349a3 100644
--- a/docs/guide/CSS.md
+++ b/docs/guide/CSS.md
@@ -321,12 +321,13 @@ The `background: green` is only applied to the Button underneath the mouse curso
Here are some other pseudo classes:
+- `:blur` Matches widgets which *do not* have input focus.
+- `:dark` Matches widgets in dark mode (where `App.dark == True`).
- `:disabled` Matches widgets which are in a disabled state.
- `:enabled` Matches widgets which are in an enabled state.
-- `:focus` Matches widgets which have input focus.
-- `:blur` Matches widgets which *do not* have input focus.
- `:focus-within` Matches widgets with a focused child widget.
-- `:dark` Matches widgets in dark mode (where `App.dark == True`).
+- `:focus` Matches widgets which have input focus.
+- `:inline` Matches widgets when the app is running in inline mode.
- `:light` Matches widgets in dark mode (where `App.dark == False`).
## Combinators
diff --git a/docs/guide/app.md b/docs/guide/app.md
index 5a59322802..7bc9e69e26 100644
--- a/docs/guide/app.md
+++ b/docs/guide/app.md
@@ -38,6 +38,14 @@ If you hit ++ctrl+c++ Textual will exit application mode and return you to the c
A side effect of application mode is that you may no longer be able to select and copy text in the usual way. Terminals typically offer a way to bypass this limit with a key modifier. On iTerm you can select text if you hold the ++option++ key. See the documentation for your terminal software for how to select text in application mode.
+#### Run inline
+
+!!! tip "Added in version 0.45.0"
+
+You can also run apps in _inline_ mode, which will cause the app to appear beneath the prompt (and won't go in to application mode).
+Inline apps are useful for tools that integrate closely with the typical workflow of a terminal.
+
+To run an app in inline mode set the `inline` parameter to `True` when you call [App.run()][textual.app.App.run]. See [Style Inline Apps](../how-to/style-inline-apps.md) for how to apply additional styles to inline apps.
## Events
diff --git a/docs/how-to/style-inline-apps.md b/docs/how-to/style-inline-apps.md
new file mode 100644
index 0000000000..cef9498da1
--- /dev/null
+++ b/docs/how-to/style-inline-apps.md
@@ -0,0 +1,37 @@
+# Style Inline apps
+
+Version 0.55.0 of Textual added support for running apps *inline* (below the prompt).
+Running an inline app is as simple as adding `inline=True` to [`run()`][textual.app.App.run].
+
+
+
+Your apps will typically run inline without modification, but you may want to make some tweaks for inline mode, which you can do with a little CSS.
+This How-To will explain how.
+
+Let's look at an inline app.
+The following app displays the the current time (and keeps it up to date).
+
+```python hl_lines="31"
+--8<-- "docs/examples/how-to/inline01.py"
+```
+
+1. The `inline=True` runs the app inline.
+
+With Textual's default settings, this clock will be displayed in 5 lines; 3 for the digits and 2 for a top and bottom border.
+
+You can change the height or the border with CSS and the `:inline` pseudo-selector, which only matches rules in inline mode.
+Let's update this app to remove the default border, and increase the height:
+
+```python hl_lines="11-17"
+--8<-- "docs/examples/how-to/inline02.py"
+```
+
+The highlighted CSS targets online inline mode.
+By setting the `height` rule on Screen we can define how many lines the app should consume when it runs.
+Setting `border: none` removes the default border when running in inline mode.
+
+We've also added a rule to change the color of the clock when running inline.
+
+## Summary
+
+Most apps will not require modification to run inline, but if you want to tweak the height and border you can write CSS that targets inline mode with the `:inline` pseudo-selector.
diff --git a/examples/calculator.py b/examples/calculator.py
index 9c8f2f9e76..8f6442ebee 100644
--- a/examples/calculator.py
+++ b/examples/calculator.py
@@ -168,4 +168,4 @@ def pressed_equals(self) -> None:
if __name__ == "__main__":
- CalculatorApp().run()
+ CalculatorApp().run(inline=True)
diff --git a/examples/calculator.tcss b/examples/calculator.tcss
index f25b387fcd..180e3f8e0b 100644
--- a/examples/calculator.tcss
+++ b/examples/calculator.tcss
@@ -12,6 +12,10 @@ Screen {
min-height: 25;
min-width: 26;
height: 100%;
+
+ &:inline {
+ margin: 0 2;
+ }
}
Button {
diff --git a/examples/code_browser.tcss b/examples/code_browser.tcss
index 05928614b3..21fc7cad00 100644
--- a/examples/code_browser.tcss
+++ b/examples/code_browser.tcss
@@ -1,5 +1,8 @@
Screen {
background: $surface-darken-1;
+ &:inline {
+ height: 50vh;
+ }
}
#tree-view {
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index 7dd0863b43..4fa9912b41 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -223,6 +223,7 @@ nav:
- "how-to/design-a-layout.md"
- "how-to/package-with-hatch.md"
- "how-to/render-and-compose.md"
+ - "how-to/style-inline-apps.md"
- "FAQ.md"
- "roadmap.md"
- "Blog":
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index 6b786e8b26..d9047e3fc3 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -144,6 +144,48 @@ def __rich_repr__(self) -> rich.repr.Result:
yield self.region
+@rich.repr.auto(angular=True)
+class InlineUpdate(CompositorUpdate):
+ """A renderable to write an inline update."""
+
+ def __init__(self, strips: list[Strip]) -> None:
+ self.strips = strips
+
+ def __rich_console__(
+ self, console: Console, options: ConsoleOptions
+ ) -> RenderResult:
+ new_line = Segment.line()
+ for last, line in loop_last(self.strips):
+ yield from line
+ if not last:
+ yield new_line
+
+ def render_segments(self, console: Console) -> str:
+ """Render the update to raw data, suitable for writing to terminal.
+
+ Args:
+ console: Console instance.
+
+ Returns:
+ Raw data with escape sequences.
+ """
+ sequences: list[str] = []
+ append = sequences.append
+ for last, strip in loop_last(self.strips):
+ append(strip.render(console))
+ if not last:
+ append("\n")
+ append("\n\x1b[J") # Clear down
+ if len(self.strips) > 1:
+ append(
+ f"\x1b[{len(self.strips)}A\r"
+ ) # Move cursor back to original position
+ else:
+ append("\r")
+ append("\x1b[6n") # Query new cursor position
+ return "".join(sequences)
+
+
@rich.repr.auto(angular=True)
class ChopsUpdate(CompositorUpdate):
"""A renderable that applies updated spans to the screen."""
@@ -953,7 +995,7 @@ def render_update(
"""Render an update renderable.
Args:
- full: Enable full update, or `False` for a partial update.
+ screen_stack: Screen stack list. Defaults to None.
Returns:
A renderable for the update, or `None` if no update was required.
@@ -966,6 +1008,21 @@ def render_update(
else:
return self.render_partial_update()
+ def render_inline(
+ self, size: Size, screen_stack: list[Screen] | None = None
+ ) -> RenderableType:
+ """Render an inline update.
+
+ Args:
+ size: Inline size.
+ screen_stack: Screen stack list. Defaults to None.
+
+ Returns:
+ A renderable.
+ """
+ visible_screen_stack.set([] if screen_stack is None else screen_stack)
+ return InlineUpdate(self.render_strips(size))
+
def render_full_update(self) -> LayoutUpdate:
"""Render a full update.
@@ -999,14 +1056,19 @@ def render_partial_update(self) -> ChopsUpdate | None:
chop_ends = [cut_set[1:] for cut_set in self.cuts]
return ChopsUpdate(chops, spans, chop_ends)
- def render_strips(self) -> list[Strip]:
+ def render_strips(self, size: Size | None = None) -> list[Strip]:
"""Render to a list of strips.
+ Args:
+ size: Size of render.
+
Returns:
A list of strips with the screen content.
"""
- chops = self._render_chops(self.size.region, lambda y: True)
- render_strips = [Strip.join(chop.values()) for chop in chops]
+ if size is None:
+ size = self.size
+ chops = self._render_chops(size.region, lambda y: True)
+ render_strips = [Strip.join(chop.values()) for chop in chops[: size.height]]
return render_strips
def _render_chops(
diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py
index 78a4fce4ac..fc84391f39 100644
--- a/src/textual/_xterm_parser.py
+++ b/src/textual/_xterm_parser.py
@@ -21,6 +21,8 @@
"^" + re.escape("\x1b[") + r"\?(?P\d+);(?P\d)\$y"
)
+_re_cursor_position = re.compile(r"\x1b\[(?P\d+);(?P
\d+)R")
+
BRACKETED_PASTE_START: Final[str] = "\x1b[200~"
"""Sequence received when a bracketed paste event starts."""
BRACKETED_PASTE_END: Final[str] = "\x1b[201~"
@@ -235,8 +237,7 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None:
if key_events:
break
# Or a mouse event?
- mouse_match = _re_mouse_event.match(sequence)
- if mouse_match is not None:
+ if (mouse_match := _re_mouse_event.match(sequence)) is not None:
mouse_code = mouse_match.group(0)
event = self.parse_mouse_code(mouse_code)
if event:
@@ -245,14 +246,28 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None:
# Or a mode report?
# (i.e. the terminal saying it supports a mode we requested)
- mode_report_match = _re_terminal_mode_response.match(sequence)
- if mode_report_match is not None:
+ if (
+ mode_report_match := _re_terminal_mode_response.match(
+ sequence
+ )
+ ) is not None:
if (
mode_report_match["mode_id"] == "2026"
and int(mode_report_match["setting_parameter"]) > 0
):
on_token(messages.TerminalSupportsSynchronizedOutput())
break
+
+ # Or a cursor position query?
+ if (
+ cursor_position_match := _re_cursor_position.match(sequence)
+ ) is not None:
+ row, column = cursor_position_match.groups()
+ on_token(
+ events.CursorPosition(x=int(column) - 1, y=int(row) - 1)
+ )
+ break
+
else:
if not bracketed_paste:
for event in sequence_to_key_events(character):
diff --git a/src/textual/app.py b/src/textual/app.py
index 15811232c9..f23bfbdc51 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -45,7 +45,6 @@
Type,
TypeVar,
Union,
- cast,
overload,
)
from weakref import WeakKeyDictionary, WeakSet
@@ -92,7 +91,6 @@
from .design import ColorSystem
from .dom import DOMNode, NoScreen
from .driver import Driver
-from .drivers.headless_driver import HeadlessDriver
from .errors import NoWidget
from .features import FeatureFlag, parse_features
from .file_monitor import FileMonitor
@@ -476,6 +474,9 @@ def __init__(
self._mouse_down_widget: Widget | None = None
"""The widget that was most recently mouse downed (used to create click events)."""
+ self._previous_cursor_position = Offset(0, 0)
+ """The previous cursor position"""
+
self.cursor_position = Offset(0, 0)
"""The position of the terminal cursor in screen-space.
@@ -779,12 +780,17 @@ def debug(self) -> bool:
@property
def is_headless(self) -> bool:
- """Is the driver running in 'headless' mode?
+ """Is the app running in 'headless' mode?
Headless mode is used when running tests with [run_test][textual.app.App.run_test].
"""
return False if self._driver is None else self._driver.is_headless
+ @property
+ def is_inline(self) -> bool:
+ """Is the app running in 'inline' mode?"""
+ return False if self._driver is None else self._driver.is_inline
+
@property
def screen_stack(self) -> Sequence[Screen[Any]]:
"""A snapshot of the current screen stack.
@@ -979,6 +985,7 @@ def __rich_repr__(self) -> rich.repr.Result:
@property
def animator(self) -> Animator:
+ """The animator object."""
return self._animator
@property
@@ -1444,6 +1451,9 @@ async def run_async(
self,
*,
headless: bool = False,
+ inline: bool = False,
+ inline_no_clear: bool = False,
+ mouse: bool = False,
size: tuple[int, int] | None = None,
auto_pilot: AutopilotCallbackType | None = None,
) -> ReturnType | None:
@@ -1451,6 +1461,9 @@ async def run_async(
Args:
headless: Run in headless mode (no output).
+ inline: Run the app inline (under the prompt).
+ inline_no_clear: Don't clear the app output when exiting an inline app.
+ mouse: Enable mouse support.
size: Force terminal size to `(WIDTH, HEIGHT)`,
or None to auto-detect.
auto_pilot: An auto pilot coroutine.
@@ -1501,6 +1514,9 @@ async def run_auto_pilot(
await app._process_messages(
ready_callback=None if auto_pilot is None else app_ready,
headless=headless,
+ inline=inline,
+ inline_no_clear=inline_no_clear,
+ mouse=mouse,
terminal_size=size,
)
finally:
@@ -1516,6 +1532,9 @@ def run(
self,
*,
headless: bool = False,
+ inline: bool = False,
+ inline_no_clear: bool = False,
+ mouse: bool = True,
size: tuple[int, int] | None = None,
auto_pilot: AutopilotCallbackType | None = None,
) -> ReturnType | None:
@@ -1523,6 +1542,9 @@ def run(
Args:
headless: Run in headless mode (no output).
+ inline: Run the app inline (under the prompt).
+ inline_no_clear: Don't clear the app output when exiting an inline app.
+ mouse: Enable mouse support.
size: Force terminal size to `(WIDTH, HEIGHT)`,
or None to auto-detect.
auto_pilot: An auto pilot coroutine.
@@ -1538,6 +1560,9 @@ async def run_app() -> None:
try:
await self.run_async(
headless=headless,
+ inline=inline,
+ inline_no_clear=inline_no_clear,
+ mouse=mouse,
size=size,
auto_pilot=auto_pilot,
)
@@ -2297,6 +2322,9 @@ async def _process_messages(
self,
ready_callback: CallbackType | None = None,
headless: bool = False,
+ inline: bool = False,
+ inline_no_clear: bool = False,
+ mouse: bool = True,
terminal_size: tuple[int, int] | None = None,
message_hook: Callable[[Message], None] | None = None,
) -> None:
@@ -2314,7 +2342,6 @@ async def _process_messages(
self.log.system("---")
- self.log.system(driver=self.driver_class)
self.log.system(loop=asyncio.get_running_loop())
self.log.system(features=self.features)
if constants.LOG_FILE is not None:
@@ -2406,15 +2433,26 @@ async def invoke_ready_callback() -> None:
await self._dispatch_message(load_event)
driver: Driver
- driver_class = cast(
- "type[Driver]",
- HeadlessDriver if headless else self.driver_class,
- )
+
+ driver_class: type[Driver]
+ if headless:
+ from .drivers.headless_driver import HeadlessDriver
+
+ driver_class = HeadlessDriver
+ elif inline:
+ from .drivers.linux_inline_driver import LinuxInlineDriver
+
+ driver_class = LinuxInlineDriver
+ else:
+ driver_class = self.driver_class
+
driver = self._driver = driver_class(
self,
debug=constants.DEBUG,
+ mouse=mouse,
size=terminal_size,
)
+ self.log(driver=driver)
if not self._exit:
driver.start_application_mode()
@@ -2424,6 +2462,15 @@ async def invoke_ready_callback() -> None:
await run_process_messages()
finally:
+ if self._driver.is_inline:
+ cursor_x, cursor_y = self._previous_cursor_position
+ self._driver.write(
+ Control.move(-cursor_x, -cursor_y).segment.text
+ )
+ if inline_no_clear:
+ console = Console()
+ console.print(self.screen._compositor)
+ console.print()
driver.stop_application_mode()
except Exception as error:
self._handle_exception(error)
@@ -2737,11 +2784,16 @@ def _display(self, screen: Screen, renderable: RenderableType | None) -> None:
try:
try:
if isinstance(renderable, CompositorUpdate):
+ cursor_x, cursor_y = self._previous_cursor_position
+ terminal_sequence = Control.move(
+ -cursor_x, -cursor_y
+ ).segment.text
cursor_x, cursor_y = self.cursor_position
- terminal_sequence = renderable.render_segments(console)
- terminal_sequence += Control.move_to(
+ terminal_sequence += renderable.render_segments(console)
+ terminal_sequence += Control.move(
cursor_x, cursor_y
).segment.text
+ self._previous_cursor_position = self.cursor_position
else:
segments = console.render(renderable)
terminal_sequence = console._render_buffer(segments)
diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py
index 52c13cb067..27c662dde9 100644
--- a/src/textual/css/constants.py
+++ b/src/textual/css/constants.py
@@ -68,6 +68,7 @@
"focus-within",
"focus",
"hover",
+ "inline",
"light",
}
VALID_OVERLAY: Final = {"none", "screen"}
diff --git a/src/textual/demo.tcss b/src/textual/demo.tcss
index 2e9d54e25b..fc5f45b23f 100644
--- a/src/textual/demo.tcss
+++ b/src/textual/demo.tcss
@@ -5,6 +5,9 @@
Screen {
layers: base overlay notes notifications;
overflow: hidden;
+ &:inline {
+ height: 50vh;
+ }
}
diff --git a/src/textual/driver.py b/src/textual/driver.py
index c70edf9588..f0ad89c98b 100644
--- a/src/textual/driver.py
+++ b/src/textual/driver.py
@@ -20,6 +20,7 @@ def __init__(
app: App,
*,
debug: bool = False,
+ mouse: bool = True,
size: tuple[int, int] | None = None,
) -> None:
"""Initialize a driver.
@@ -27,22 +28,30 @@ def __init__(
Args:
app: The App instance.
debug: Enable debug mode.
+ mouse: Enable mouse support,
size: Initial size of the terminal or `None` to detect.
"""
self._app = app
self._debug = debug
+ self._mouse = mouse
self._size = size
self._loop = asyncio.get_running_loop()
self._down_buttons: list[int] = []
self._last_move_event: events.MouseMove | None = None
self._auto_restart = True
"""Should the application auto-restart (where appropriate)?"""
+ self.cursor_origin: tuple[int, int] | None = None
@property
def is_headless(self) -> bool:
"""Is the driver 'headless' (no output)?"""
return False
+ @property
+ def is_inline(self) -> bool:
+ """Is the driver 'inline' (not full-screen)?"""
+ return False
+
@property
def can_suspend(self) -> bool:
"""Can this driver be suspended?"""
@@ -67,6 +76,17 @@ def process_event(self, event: events.Event) -> None:
# NOTE: This runs in a thread.
# Avoid calling methods on the app.
event.set_sender(self._app)
+ if self.cursor_origin is None:
+ offset_x = 0
+ offset_y = 0
+ else:
+ offset_x, offset_y = self.cursor_origin
+ if isinstance(event, events.MouseEvent):
+ event.x -= offset_x
+ event.y -= offset_y
+ event.screen_x -= offset_x
+ event.screen_y -= offset_y
+
if isinstance(event, events.MouseDown):
if event.button:
self._down_buttons.append(event.button)
@@ -79,6 +99,7 @@ def process_event(self, event: events.Event) -> None:
and not event.button
and self._last_move_event is not None
):
+
# Deduplicate self._down_buttons while preserving order.
buttons = list(dict.fromkeys(self._down_buttons).keys())
self._down_buttons.clear()
diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py
index 3e7958837c..8b38190dc7 100644
--- a/src/textual/drivers/linux_driver.py
+++ b/src/textual/drivers/linux_driver.py
@@ -32,6 +32,7 @@ def __init__(
app: App,
*,
debug: bool = False,
+ mouse: bool = True,
size: tuple[int, int] | None = None,
) -> None:
"""Initialize Linux driver.
@@ -39,9 +40,10 @@ def __init__(
Args:
app: The App instance.
debug: Enable debug mode.
+ mouse: Enable mouse support.
size: Initial size of the terminal or `None` to detect.
"""
- super().__init__(app, debug=debug, size=size)
+ super().__init__(app, debug=debug, mouse=mouse, size=size)
self._file = sys.__stderr__
self.fileno = sys.__stdin__.fileno()
self.attrs_before: list[Any] | None = None
@@ -111,6 +113,8 @@ def _get_terminal_size(self) -> tuple[int, int]:
def _enable_mouse_support(self) -> None:
"""Enable reporting of mouse events."""
+ if not self._mouse:
+ return
write = self.write
write("\x1b[?1000h") # SET_VT200_MOUSE
write("\x1b[?1003h") # SET_ANY_EVENT_MOUSE
@@ -133,6 +137,8 @@ def _disable_bracketed_paste(self) -> None:
def _disable_mouse_support(self) -> None:
"""Disable reporting of mouse events."""
+ if not self._mouse:
+ return
write = self.write
write("\x1b[?1000l") #
write("\x1b[?1003l") #
diff --git a/src/textual/drivers/linux_inline_driver.py b/src/textual/drivers/linux_inline_driver.py
new file mode 100644
index 0000000000..c43ff33e54
--- /dev/null
+++ b/src/textual/drivers/linux_inline_driver.py
@@ -0,0 +1,274 @@
+from __future__ import annotations
+
+import asyncio
+import os
+import selectors
+import signal
+import sys
+import termios
+import tty
+from codecs import getincrementaldecoder
+from threading import Event, Thread
+from typing import TYPE_CHECKING, Any
+
+import rich.repr
+
+from .. import events
+from .._xterm_parser import XTermParser
+from ..driver import Driver
+from ..geometry import Size
+
+if TYPE_CHECKING:
+ from ..app import App
+
+
+@rich.repr.auto(angular=True)
+class LinuxInlineDriver(Driver):
+
+ def __init__(
+ self,
+ app: App,
+ *,
+ debug: bool = False,
+ mouse: bool = True,
+ size: tuple[int, int] | None = None,
+ ):
+ super().__init__(app, debug=debug, mouse=mouse, size=size)
+ self._file = sys.__stderr__
+ self.fileno = sys.__stdin__.fileno()
+ self.attrs_before: list[Any] | None = None
+ self.exit_event = Event()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield self._app
+
+ @property
+ def is_inline(self) -> bool:
+ return True
+
+ def _enable_bracketed_paste(self) -> None:
+ """Enable bracketed paste mode."""
+ self.write("\x1b[?2004h")
+
+ def _disable_bracketed_paste(self) -> None:
+ """Disable bracketed paste mode."""
+ self.write("\x1b[?2004l")
+
+ def _get_terminal_size(self) -> tuple[int, int]:
+ """Detect the terminal size.
+
+ Returns:
+ The size of the terminal as a tuple of (WIDTH, HEIGHT).
+ """
+ width: int | None = 80
+ height: int | None = 25
+ import shutil
+
+ try:
+ width, height = shutil.get_terminal_size()
+ except (AttributeError, ValueError, OSError):
+ try:
+ width, height = shutil.get_terminal_size()
+ except (AttributeError, ValueError, OSError):
+ pass
+ width = width or 80
+ height = height or 25
+ return width, height
+
+ def _enable_mouse_support(self) -> None:
+ """Enable reporting of mouse events."""
+ if not self._mouse:
+ return
+ write = self.write
+ write("\x1b[?1000h") # SET_VT200_MOUSE
+ write("\x1b[?1003h") # SET_ANY_EVENT_MOUSE
+ write("\x1b[?1015h") # SET_VT200_HIGHLIGHT_MOUSE
+ write("\x1b[?1006h") # SET_SGR_EXT_MODE_MOUSE
+
+ # write("\x1b[?1007h")
+ self.flush()
+
+ def _disable_mouse_support(self) -> None:
+ """Disable reporting of mouse events."""
+ if not self._mouse:
+ return
+ write = self.write
+ write("\x1b[?1000l") #
+ write("\x1b[?1003l") #
+ write("\x1b[?1015l")
+ write("\x1b[?1006l")
+ self.flush()
+
+ def write(self, data: str) -> None:
+ self._file.write(data)
+
+ def _run_input_thread(self) -> None:
+ """
+ Key thread target that wraps run_input_thread() to die gracefully if it raises
+ an exception
+ """
+ try:
+ self.run_input_thread()
+ except BaseException as error:
+ import rich.traceback
+
+ self._app.call_later(
+ self._app.panic,
+ rich.traceback.Traceback(),
+ )
+
+ def run_input_thread(self) -> None:
+ """Wait for input and dispatch events."""
+ selector = selectors.SelectSelector()
+ selector.register(self.fileno, selectors.EVENT_READ)
+
+ fileno = self.fileno
+ EVENT_READ = selectors.EVENT_READ
+
+ def more_data() -> bool:
+ """Check if there is more data to parse."""
+
+ for _key, events in selector.select(0.01):
+ if events & EVENT_READ:
+ return True
+ return False
+
+ parser = XTermParser(more_data, self._debug)
+ feed = parser.feed
+
+ utf8_decoder = getincrementaldecoder("utf-8")().decode
+ decode = utf8_decoder
+ read = os.read
+
+ try:
+ while not self.exit_event.is_set():
+ selector_events = selector.select(0.1)
+ for _selector_key, mask in selector_events:
+ if mask & EVENT_READ:
+ unicode_data = decode(
+ read(fileno, 1024), final=self.exit_event.is_set()
+ )
+ for event in feed(unicode_data):
+ if isinstance(event, events.CursorPosition):
+ self.cursor_origin = (event.x, event.y)
+ else:
+ self.process_event(event)
+ finally:
+ selector.close()
+
+ def start_application_mode(self) -> None:
+
+ loop = asyncio.get_running_loop()
+
+ def send_size_event() -> None:
+ terminal_size = self._get_terminal_size()
+ width, height = terminal_size
+ textual_size = Size(width, height)
+ event = events.Resize(textual_size, textual_size)
+ asyncio.run_coroutine_threadsafe(
+ self._app._post_message(event),
+ loop=loop,
+ )
+
+ def on_terminal_resize(signum, stack) -> None:
+ self.write("\x1b[2J")
+ self.flush()
+ send_size_event()
+
+ signal.signal(signal.SIGWINCH, on_terminal_resize)
+
+ self.write("\x1b[?25l") # Hide cursor
+ self.write("\033[?1004h\n") # Enable FocusIn/FocusOut.
+
+ self._enable_mouse_support()
+ try:
+ self.attrs_before = termios.tcgetattr(self.fileno)
+ except termios.error:
+ # Ignore attribute errors.
+ self.attrs_before = None
+
+ try:
+ newattr = termios.tcgetattr(self.fileno)
+ except termios.error:
+ pass
+ else:
+ newattr[tty.LFLAG] = self._patch_lflag(newattr[tty.LFLAG])
+ newattr[tty.IFLAG] = self._patch_iflag(newattr[tty.IFLAG])
+
+ # VMIN defines the number of characters read at a time in
+ # non-canonical mode. It seems to default to 1 on Linux, but on
+ # Solaris and derived operating systems it defaults to 4. (This is
+ # because the VMIN slot is the same as the VEOF slot, which
+ # defaults to ASCII EOT = Ctrl-D = 4.)
+ newattr[tty.CC][termios.VMIN] = 1
+
+ termios.tcsetattr(self.fileno, termios.TCSANOW, newattr)
+
+ self._key_thread = Thread(target=self._run_input_thread)
+ send_size_event()
+ self._key_thread.start()
+
+ @classmethod
+ def _patch_lflag(cls, attrs: int) -> int:
+ """Patch termios lflag.
+
+ Args:
+ attributes: New set attributes.
+
+ Returns:
+ New lflag.
+
+ """
+ # if TEXTUAL_ALLOW_SIGNALS env var is set, then allow Ctrl+C to send signals
+ ISIG = 0 if os.environ.get("TEXTUAL_ALLOW_SIGNALS") else termios.ISIG
+
+ return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | ISIG)
+
+ @classmethod
+ def _patch_iflag(cls, attrs: int) -> int:
+ return attrs & ~(
+ # Disable XON/XOFF flow control on output and input.
+ # (Don't capture Ctrl-S and Ctrl-Q.)
+ # Like executing: "stty -ixon."
+ termios.IXON
+ | termios.IXOFF
+ |
+ # Don't translate carriage return into newline on input.
+ termios.ICRNL
+ | termios.INLCR
+ | termios.IGNCR
+ )
+
+ def disable_input(self) -> None:
+ """Disable further input."""
+ try:
+ if not self.exit_event.is_set():
+ signal.signal(signal.SIGWINCH, signal.SIG_DFL)
+ self._disable_mouse_support()
+ self.exit_event.set()
+ if self._key_thread is not None:
+ self._key_thread.join()
+ self.exit_event.clear()
+ termios.tcflush(self.fileno, termios.TCIFLUSH)
+
+ except Exception as error:
+ # TODO: log this
+ pass
+
+ def stop_application_mode(self) -> None:
+ """Stop application mode, restore state."""
+ self._disable_bracketed_paste()
+ self.disable_input()
+
+ self.write("\x1b[A\x1b[J")
+
+ if self.attrs_before is not None:
+ try:
+ termios.tcsetattr(self.fileno, termios.TCSANOW, self.attrs_before)
+ except termios.error:
+ pass
+
+ self.write("\x1b[?25h") # Show cursor
+ self.write("\033[?1004l\n") # Disable FocusIn/FocusOut.
+
+ self.flush()
diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py
index 535e3109c0..e1fc7106a6 100644
--- a/src/textual/drivers/web_driver.py
+++ b/src/textual/drivers/web_driver.py
@@ -40,7 +40,12 @@ class WebDriver(Driver):
"""A headless driver that may be run remotely."""
def __init__(
- self, app: App, *, debug: bool = False, size: tuple[int, int] | None = None
+ self,
+ app: App,
+ *,
+ debug: bool = False,
+ mouse: bool = True,
+ size: tuple[int, int] | None = None,
):
if size is None:
try:
@@ -50,7 +55,7 @@ def __init__(
pass
else:
size = width, height
- super().__init__(app, debug=debug, size=size)
+ super().__init__(app, debug=debug, mouse=mouse, size=size)
self.stdout = sys.__stdout__
self.fileno = sys.__stdout__.fileno()
self._write = partial(os.write, self.fileno)
diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py
index 1df31728ac..9c53d4f6da 100644
--- a/src/textual/drivers/windows_driver.py
+++ b/src/textual/drivers/windows_driver.py
@@ -21,6 +21,7 @@ def __init__(
app: App,
*,
debug: bool = False,
+ mouse: bool = True,
size: tuple[int, int] | None = None,
) -> None:
"""Initialize Windows driver.
@@ -28,9 +29,10 @@ def __init__(
Args:
app: The App instance.
debug: Enable debug mode.
+ mouse: Enable mouse support.
size: Initial size of the terminal or `None` to detect.
"""
- super().__init__(app, debug=debug, size=size)
+ super().__init__(app, debug=debug, mouse=mouse, size=size)
self._file = sys.__stdout__
self.exit_event = Event()
self._event_thread: Thread | None = None
@@ -53,6 +55,8 @@ def write(self, data: str) -> None:
def _enable_mouse_support(self) -> None:
"""Enable reporting of mouse events."""
+ if not self._mouse:
+ return
write = self.write
write("\x1b[?1000h") # SET_VT200_MOUSE
write("\x1b[?1003h") # SET_ANY_EVENT_MOUSE
@@ -62,6 +66,8 @@ def _enable_mouse_support(self) -> None:
def _disable_mouse_support(self) -> None:
"""Disable reporting of mouse events."""
+ if not self._mouse:
+ return
write = self.write
write("\x1b[?1000l")
write("\x1b[?1003l")
diff --git a/src/textual/events.py b/src/textual/events.py
index 68c3ae8f3d..ce5ecac4e3 100644
--- a/src/textual/events.py
+++ b/src/textual/events.py
@@ -55,6 +55,14 @@ class Shutdown(Event):
pass
+@dataclass
+class CursorPosition(Event, bubble=False):
+ """Internal event used to retrieve the terminal's cursor position."""
+
+ x: int
+ y: int
+
+
class Load(Event, bubble=False):
"""
Sent when the App is running but *before* the terminal is in application mode.
diff --git a/src/textual/screen.py b/src/textual/screen.py
index b9713e7256..aa6410e31e 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -137,6 +137,13 @@ class Screen(Generic[ScreenResultType], Widget):
layout: vertical;
overflow-y: auto;
background: $surface;
+
+ &:inline {
+ height: auto;
+ min-height: 1;
+ border-top: tall $background;
+ border-bottom: tall $background;
+ }
}
"""
@@ -664,7 +671,20 @@ async def _on_idle(self, event: events.Idle) -> None:
def _compositor_refresh(self) -> None:
"""Perform a compositor refresh."""
- if self is self.app.screen:
+
+ if self.app.is_inline:
+ size = self.app.size
+ self.app._display(
+ self,
+ self._compositor.render_inline(
+ Size(size.width, self._get_inline_height(size)),
+ screen_stack=self.app._background_screens,
+ ),
+ )
+ self._dirty_widgets.clear()
+ self._compositor._dirty_regions.clear()
+
+ elif self is self.app.screen:
# Top screen
update = self._compositor.render_update(
screen_stack=self.app._background_screens
@@ -754,6 +774,8 @@ def _pop_result_callback(self) -> None:
def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> None:
"""Refresh the layout (can change size and positions of widgets)."""
size = self.outer_size if size is None else size
+ if self.app.is_inline:
+ size = Size(size.width, self._get_inline_height(self.app.size))
if not size:
return
self._compositor.update_widgets(self._dirty_widgets)
@@ -846,6 +868,30 @@ async def _on_update_scroll(self, message: messages.UpdateScroll) -> None:
self._scroll_required = True
self.check_idle()
+ def _get_inline_height(self, size: Size) -> int:
+ """Get the inline height (number of lines to display when running inline mode).
+
+ Args:
+ size: Size of the terminal
+
+ Returns:
+ Height for inline mode.
+ """
+ height_scalar = self.styles.height
+ if height_scalar is None or height_scalar.is_auto:
+ inline_height = self.get_content_height(size, size, size.width)
+ else:
+ inline_height = int(height_scalar.resolve(size, size))
+ inline_height += self.styles.gutter.height
+ min_height = self.styles.min_height
+ max_height = self.styles.max_height
+ if min_height is not None:
+ inline_height = max(inline_height, int(min_height.resolve(size, size)))
+ if max_height is not None:
+ inline_height = min(inline_height, int(max_height.resolve(size, size)))
+ inline_height = min(self.app.size.height - 1, inline_height)
+ return inline_height
+
def _screen_resized(self, size: Size):
"""Called by App when the screen is resized."""
self._refresh_layout(size)
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 4bfaabdee0..5158d524bf 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -3064,6 +3064,8 @@ def get_pseudo_classes(self) -> Iterable[str]:
yield "focus-within"
break
node = node._parent
+ if self.app.is_inline:
+ yield "inline"
def get_pseudo_class_state(self) -> PseudoClasses:
"""Get an object describing whether each pseudo class is present on this object or not.