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

inline driver #4343

Merged
merged 22 commits into from
Mar 30, 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions docs/examples/how-to/inline01.py
Original file line number Diff line number Diff line change
@@ -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)!
38 changes: 38 additions & 0 deletions docs/examples/how-to/inline02.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion docs/examples/widgets/clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ def update_clock(self) -> None:

if __name__ == "__main__":
app = ClockApp()
app.run()
app.run(inline=True)
7 changes: 4 additions & 3 deletions docs/guide/CSS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/guide/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 37 additions & 0 deletions docs/how-to/style-inline-apps.md
Original file line number Diff line number Diff line change
@@ -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].

<iframe width="100%" style="aspect-ratio:757/804;" src="https://www.youtube.com/embed/dxAf3vDr4aQ" title="Textual Inline mode" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

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.
2 changes: 1 addition & 1 deletion examples/calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,4 @@ def pressed_equals(self) -> None:


if __name__ == "__main__":
CalculatorApp().run()
CalculatorApp().run(inline=True)
4 changes: 4 additions & 0 deletions examples/calculator.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Screen {
min-height: 25;
min-width: 26;
height: 100%;

&:inline {
margin: 0 2;
}
}

Button {
Expand Down
3 changes: 3 additions & 0 deletions examples/code_browser.tcss
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
Screen {
background: $surface-darken-1;
&:inline {
height: 50vh;
}
}

#tree-view {
Expand Down
1 change: 1 addition & 0 deletions mkdocs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
70 changes: 66 additions & 4 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand Down Expand Up @@ -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(
Expand Down
23 changes: 19 additions & 4 deletions src/textual/_xterm_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\d)\$y"
)

_re_cursor_position = re.compile(r"\x1b\[(?P<row>\d+);(?P<col>\d+)R")

BRACKETED_PASTE_START: Final[str] = "\x1b[200~"
"""Sequence received when a bracketed paste event starts."""
BRACKETED_PASTE_END: Final[str] = "\x1b[201~"
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
Loading
Loading