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.