Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

flush inline #4435

Merged
merged 5 commits into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased
## [0.57.1] - 2024-04-20

### Fixed

- Fixed an off-by-one error in the line number of the `Document.end` property https://github.com/Textualize/textual/issues/4426
- Fixed setting scrollbar colors not updating the scrollbar https://github.com/Textualize/textual/pull/4433
- Fixed flushing in inline mode https://github.com/Textualize/textual/pull/4435

### Added

- Added `Offset.clamp` and `Size.clamp_offset` https://github.com/Textualize/textual/pull/4435


## [0.57.0] - 2024-04-19

Expand Down
Binary file added docs/blog/images/calcinline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions docs/blog/images/inline1.excalidraw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions docs/blog/images/inline2.excalidraw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
134 changes: 134 additions & 0 deletions docs/blog/posts/inline-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
draft: true
date: 2024-04-20
categories:
- DevLog
authors:
- willmcgugan
---

# Behind the Curtain of Inline Terminal Applications

Textual recently added the ability to run *inline* terminal apps.
You can see this in action if you run the [calculator example](https://github.com/Textualize/textual/blob/main/examples/calculator.py):

![Inline Calculator](../images/calcinline.png)

The application appears directly under the prompt, rather than occupying the full height of the screen—which is more typical of TUI applications.
You can interact with the calculator using keys *or* the mouse.
When you press ++ctrl+c++ the calculator disappears and returns you to the prompt.

Here's another app that creates an inline code editor:

=== "Video"

<div class="video-wrapper">
<iframe width="852" height="525" src="https://www.youtube.com/embed/Dt70oSID1DY" title="Inline app" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div>


=== "inline.py"
```python
from textual.app import App, ComposeResult
from textual.widgets import TextArea


class InlineApp(App):
CSS = """
TextArea {
height: auto;
max-height: 50vh;
}
"""

def compose(self) -> ComposeResult:
yield TextArea(language="python")


if __name__ == "__main__":
InlineApp().run(inline=True)

```

This post will cover some of what goes on under the hood to make such inline apps work.


## Programming the terminal

Firstly, let's recap how you program the terminal.
Broadly speaking, the terminal is a device for displaying text.
You write (or print) text to the terminal which typically appears at the end of a continually expanding text buffer.
In addition to text you can also send [escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code), which are short sequences of characters that instruct the terminal to do things such as change the text color, scroll, or other more exotic things.

We only need a few of these escape codes to implement inline apps.

!!! note

I will gloss over the exact characters used for these escape codes.
It's enough to know that they exist for now.
If you implement any of this yourself, refer to the [wikipedia article](https://en.wikipedia.org/wiki/ANSI_escape_code).

## Rendering frames

The first step is to display the app, which is simply text (possibly with escape sequences to change color and style).
The lines are terminated with a newline character (`"\n"`), *except* for the very last line (otherwise we get a blank line a the end which we don't need).
Rather than a final newline, we write an escape code that moves the *cursor* back to it's prior position.

The cursor is where text will be written.
It's the same cursor you see as you type.
Normally it will be at the end of the text in the terminal, but it can be moved around terminal with escape codes.
It can be made invisible (as in Textual apps), but the terminal will keep track of the cursor, even if it can not be seen.

Because we move the cursor back to its original starting position, when we write a subsequent frame it overwrites the previous frame.
Here's a diagram that shows what happens.

!!! note

I've drawn the cursor in red, although it isn't typically visible.


<div class="excalidraw">
--8<-- "docs/blog/images/inline1.excalidraw.svg"
</div>


There is an additional consideration that comes in to play when the output has less lines than the previous frame.
If we were to write a shorter frame, it wouldn't fully overwrite the previous frame.
We would be left with a few lines of a previous frame that wouldn't update.

The solution to this problem is to write an escape code that clears lines from the cursor downwards before we write a smaller frame.

## Cursor input

The cursor tells the terminal where any text will be written by the app, but it also assumes this will be where the user enters text.
If you enter CJK (Chinese Japanese Korean) text in to the terminal, you will typically see a floating control that points where new text will be written. If you are on a Mac, the emoji entry dialog (++ctrl+cmd+space++) will also point at the current cursor position. To make this work in a sane way, we need to move the terminal's cursor to where any new text will appear.

<div class="excalidraw">
--8<-- "docs/blog/images/inline2.excalidraw.svg"
</div>

This only really impacts text entry (such as the [Input](https://textual.textualize.io/widget_gallery/#input) and [TextArea](https://textual.textualize.io/widget_gallery/#textarea) widgets).

## Mouse control

Inline apps in Textual also support mouse input.
This works in almost the same way as fullscreen apps.
There is an escape code to enable mouse input which sends encoded mouse coordinates to the app via standard input (in much the same way as you would read keys).

The only real challenge with using the mouse in inline apps is that the mouse coordinates are relative to the top left of the terminal window (*not* the top left of our frame).
To work around this difference, we need to detect where the cursor is relative to the terminal.

The only way I have discovered to do this, is to query the cursor position from the terminal, which we can do by sending an escape sequence then reading the cursor position from standard input. Once we have this information we can work out the mouse position relative to our frame, so that the app knows where you are clicking.

## tl;dr

[Escapes codes](https://en.wikipedia.org/wiki/ANSI_escape_code).

## Found this interesting?

If you are interested in Textual, join our [Discord server](https://discord.gg/Enf6Z3qhVr).

Or follow me for more terminal shenanigans.

- [@willmcgugan](https://twitter.com/willmcgugan)
- [mastodon.social/@willmcgugan](https://mastodon.social/@willmcgugan)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
version = "0.57.0"
version = "0.57.1"
homepage = "https://github.com/Textualize/textual"
repository = "https://github.com/Textualize/textual"
documentation = "https://textual.textualize.io/"
Expand Down
9 changes: 6 additions & 3 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2840,20 +2840,23 @@ def _display(self, screen: Screen, renderable: RenderableType | None) -> None:
try:
try:
if isinstance(renderable, CompositorUpdate):
cursor_position = self.screen.size.clamp_offset(
self.cursor_position
)
if self._driver.is_inline:
terminal_sequence = Control.move(
*(-self._previous_cursor_position)
).segment.text
terminal_sequence += renderable.render_segments(console)
terminal_sequence += Control.move(
*self.cursor_position
*cursor_position
).segment.text
else:
terminal_sequence = renderable.render_segments(console)
terminal_sequence += Control.move_to(
*self.cursor_position
*cursor_position
).segment.text
self._previous_cursor_position = self.cursor_position
self._previous_cursor_position = cursor_position
else:
segments = console.render(renderable)
terminal_sequence = console._render_buffer(segments)
Expand Down
4 changes: 4 additions & 0 deletions src/textual/drivers/linux_inline_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ def disable_input(self) -> None:
# TODO: log this
pass

def flush(self):
"""Flush any buffered data."""
self._file.flush()

def stop_application_mode(self) -> None:
"""Stop application mode, restore state."""
self._disable_bracketed_paste()
Expand Down
24 changes: 24 additions & 0 deletions src/textual/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,19 @@ def get_distance_to(self, other: Offset) -> float:
distance: float = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) ** 0.5
return distance

def clamp(self, width: int, height: int) -> Offset:
"""Clamp the offset to fit within a rectangle of width x height.

Args:
width: Width to clamp.
height: Height to clamp.

Returns:
A new offset.
"""
x, y = self
return Offset(clamp(x, 0, width), clamp(y, 0, height))


class Size(NamedTuple):
"""The dimensions (width and height) of a rectangular region.
Expand Down Expand Up @@ -268,6 +281,17 @@ def __contains__(self, other: Any) -> bool:
width, height = self
return width > x >= 0 and height > y >= 0

def clamp_offset(self, offset: Offset) -> Offset:
"""Clamp an offset to fit within the width x heigh.

Args:
offset: An offset.

Returns:
A new offset that will fit inside the dimensions defined in the Size.
"""
return offset.clamp(self.width, self.height)


class Region(NamedTuple):
"""Defines a rectangular region.
Expand Down
Loading