Skip to content

Commit

Permalink
Merge branch 'main' into list-view-fix
Browse files Browse the repository at this point in the history
  • Loading branch information
willmcgugan authored Oct 24, 2024
2 parents 4afe679 + b82f5bf commit b75269f
Show file tree
Hide file tree
Showing 47 changed files with 1,733 additions and 173 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,23 @@ 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

### Changed

- Grid will now size children to the maximum height of a row https://github.com/Textualize/textual/pull/5113
- Markdown links will be opened with `App.open_url` automatically https://github.com/Textualize/textual/pull/5113
- The universal selector (`*`) will now not match widgets with the class `-textual-system` (scrollbars, notifications etc) https://github.com/Textualize/textual/pull/5113

### Added

- Added Link widget https://github.com/Textualize/textual/pull/5113
- Added `open_links` to `Markdown` and `MarkdownViewer` widgets https://github.com/Textualize/textual/pull/5113
- Added `App.DEFAULT_MODE` https://github.com/Textualize/textual/pull/5113
- Added `Containers.HorizontalGroup` and `Containers.VerticalGroup` https://github.com/Textualize/textual/pull/5113
- Added `$`, `£`, ``, `(`, `)` symbols to Digits https://github.com/Textualize/textual/pull/5113
- Added `Button.action` parameter to invoke action when clicked https://github.com/Textualize/textual/pull/5113
- Added `immediate` parameter to scroll methods https://github.com/Textualize/textual/pull/5164
- Added `textual._loop.loop_from_index` https://github.com/Textualize/textual/pull/5164

Expand Down
6 changes: 6 additions & 0 deletions docs/api/layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: "textual.layout"
---


::: textual.layout
23 changes: 23 additions & 0 deletions docs/examples/widgets/link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from textual.app import App, ComposeResult
from textual.widgets import Link


class LabelApp(App):
AUTO_FOCUS = None
CSS = """
Screen {
align: center middle;
}
"""

def compose(self) -> ComposeResult:
yield Link(
"Go to textualize.io",
url="https://textualize.io",
tooltip="Click me",
)


if __name__ == "__main__":
app = LabelApp()
app.run()
7 changes: 7 additions & 0 deletions docs/widget_gallery.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ A simple text label.
[Label reference](./widgets/label.md){ .md-button .md-button--primary }


## Link

A clickable link that opens a URL.

[Link reference](./widgets/link.md){ .md-button .md-button--primary }


## ListView

Display a list of items (items may be other widgets).
Expand Down
61 changes: 61 additions & 0 deletions docs/widgets/link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Link

!!! tip "Added in version 0.84.0"

A widget to display a piece of text that opens a URL when clicked, like a web browser link.

- [x] Focusable
- [ ] Container


## Example

A trivial app with a link.
Clicking the link open's a web-browser—as you might expect!

=== "Output"

```{.textual path="docs/examples/widgets/link.py"}
```

=== "link.py"

```python
--8<-- "docs/examples/widgets/link.py"
```


## Reactive Attributes

| Name | Type | Default | Description |
| ------ | ----- | ------- | ----------------------------------------- |
| `text` | `str` | `""` | The text of the link. |
| `url` | `str` | `""` | The URL to open when the link is clicked. |


## Messages

This widget sends no messages.

## Bindings

The Link widget defines the following bindings:

::: textual.widgets.Link.BINDINGS
options:
show_root_heading: false
show_root_toc_entry: false


## Component classes

This widget contains no component classes.



---


::: textual.widgets.Link
options:
heading_level: 2
2 changes: 1 addition & 1 deletion docs/widgets/masked_input.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The example below shows a masked input to ease entering a credit card number.
```{.textual path="docs/examples/widgets/masked_input.py"}
```

=== "checkbox.py"
=== "masked_input.py"

```python
--8<-- "docs/examples/widgets/masked_input.py"
Expand Down
2 changes: 2 additions & 0 deletions mkdocs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ nav:
- "widgets/index.md"
- "widgets/input.md"
- "widgets/label.md"
- "widgets/link.md"
- "widgets/list_item.md"
- "widgets/list_view.md"
- "widgets/loading_indicator.md"
Expand Down Expand Up @@ -195,6 +196,7 @@ nav:
- "api/filter.md"
- "api/fuzzy_matcher.md"
- "api/geometry.md"
- "api/layout.md"
- "api/lazy.md"
- "api/logger.md"
- "api/logging.md"
Expand Down
4 changes: 2 additions & 2 deletions src/textual/_arrange.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
from operator import attrgetter
from typing import TYPE_CHECKING, Iterable, Mapping, Sequence

from textual._layout import DockArrangeResult, WidgetPlacement
from textual._partition import partition
from textual.geometry import Region, Size, Spacing
from textual.layout import DockArrangeResult, WidgetPlacement

if TYPE_CHECKING:
from textual.widget import Widget
Expand Down Expand Up @@ -90,7 +90,7 @@ def arrange(

if layout_widgets:
# Arrange layout widgets (i.e. not docked)
layout_placements = widget._layout.arrange(
layout_placements = widget.layout.arrange(
widget,
layout_widgets,
dock_region.size,
Expand Down
6 changes: 6 additions & 0 deletions src/textual/_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def resolve(
gutter: int,
size: Size,
viewport: Size,
min_size: int | None = None,
) -> list[tuple[int, int]]:
"""Resolve a list of dimensions.
Expand Down Expand Up @@ -62,6 +63,11 @@ def resolve(
"list[Fraction]", [fraction for _, fraction in resolved]
)

if min_size is not None:
resolved_fractions = [
max(Fraction(min_size), fraction) for fraction in resolved_fractions
]

fraction_gutter = Fraction(gutter)
offsets = [0] + [
int(fraction)
Expand Down
77 changes: 68 additions & 9 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ class App(Generic[ReturnType], DOMNode):
overflow-y: auto !important;
align: center middle;
.-maximized {
dock: initial !important;
dock: initial !important;
}
}
/* Fade the header title when app is blurred */
Expand Down Expand Up @@ -413,6 +413,9 @@ class MyApp(App[None]):
...
```
"""
DEFAULT_MODE: ClassVar[str] = "_default"
"""Name of the default mode."""

SCREENS: ClassVar[dict[str, Callable[[], Screen[Any]]]] = {}
"""Screens associated with the app for the lifetime of the app."""

Expand Down Expand Up @@ -490,6 +493,9 @@ class MyApp(App[None]):
SUSPENDED_SCREEN_CLASS: ClassVar[str] = ""
"""Class to apply to suspended screens, or empty string for no class."""

HOVER_EFFECTS_SCROLL_PAUSE: ClassVar[float] = 0.2
"""Seconds to pause hover effects for when scrolling."""

_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App], bool]]] = {
"focus": lambda app: app.app_focus,
"blur": lambda app: not app.app_focus,
Expand Down Expand Up @@ -587,9 +593,9 @@ def __init__(
self._workers = WorkerManager(self)
self.error_console = Console(markup=False, highlight=False, stderr=True)
self.driver_class = driver_class or self.get_driver_class()
self._screen_stacks: dict[str, list[Screen[Any]]] = {"_default": []}
self._screen_stacks: dict[str, list[Screen[Any]]] = {self.DEFAULT_MODE: []}
"""A stack of screens per mode."""
self._current_mode: str = "_default"
self._current_mode: str = self.DEFAULT_MODE
"""The current mode the app is in."""
self._sync_available = False

Expand Down Expand Up @@ -775,6 +781,11 @@ def __init__(
self._previous_inline_height: int | None = None
"""Size of previous inline update."""

self._paused_hover_effects: bool = False
"""Have the hover effects been paused?"""

self._hover_effects_timer: Timer | None = None

if self.ENABLE_COMMAND_PALETTE:
for _key, binding in self._bindings:
if binding.action in {"command_palette", "app.command_palette"}:
Expand Down Expand Up @@ -2171,8 +2182,12 @@ def _init_mode(self, mode: str) -> AwaitMount:

stack = self._screen_stacks.get(mode, [])
if stack:
await_mount = AwaitMount(stack[0], [])
else:
# Mode already exists
# Return an dummy await
return AwaitMount(stack[0], [])

if mode in self._modes:
# Mode is defined in MODES
_screen = self._modes[mode]
if isinstance(_screen, Screen):
raise TypeError(
Expand All @@ -2183,6 +2198,17 @@ def _init_mode(self, mode: str) -> AwaitMount:
screen, await_mount = self._get_screen(new_screen)
stack.append(screen)
self._load_screen_css(screen)
self.refresh_css()
screen.post_message(events.ScreenResume())
else:
# Mode is not defined
screen = self.get_default_screen()
stack.append(screen)
self._register(self, screen)
screen.post_message(events.ScreenResume())
await_mount = AwaitMount(stack[0], [])

screen._screen_resized(self.size)

self._screen_stacks[mode] = stack
return await_mount
Expand All @@ -2199,7 +2225,12 @@ def switch_mode(self, mode: str) -> AwaitMount:
Raises:
UnknownModeError: If trying to switch to an unknown mode.
"""

if mode == self._current_mode:
return AwaitMount(self.screen, [])

if mode not in self._modes:
raise UnknownModeError(f"No known mode {mode!r}")

Expand Down Expand Up @@ -2673,12 +2704,43 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
"""
self.screen.set_focus(widget, scroll_visible)

def _pause_hover_effects(self):
"""Pause any hover effects based on Enter and Leave events for 200ms."""
if not self.HOVER_EFFECTS_SCROLL_PAUSE or self.is_headless:
return
self._paused_hover_effects = True
if self._hover_effects_timer is None:
self._hover_effects_timer = self.set_interval(
self.HOVER_EFFECTS_SCROLL_PAUSE, self._resume_hover_effects
)
else:
self._hover_effects_timer.reset()
self._hover_effects_timer.resume()

def _resume_hover_effects(self):
"""Resume sending Enter and Leave for hover effects."""
if not self.HOVER_EFFECTS_SCROLL_PAUSE or self.is_headless:
return
if self._paused_hover_effects:
self._paused_hover_effects = False
if self._hover_effects_timer is not None:
self._hover_effects_timer.pause()
try:
widget, _ = self.screen.get_widget_at(*self.mouse_position)
except NoWidget:
pass
else:
if widget is not self.mouse_over:
self._set_mouse_over(widget)

def _set_mouse_over(self, widget: Widget | None) -> None:
"""Called when the mouse is over another widget.
Args:
widget: Widget under mouse, or None for no widgets.
"""
if self._paused_hover_effects:
return
if widget is None:
if self.mouse_over is not None:
try:
Expand Down Expand Up @@ -3505,10 +3567,7 @@ async def on_event(self, event: events.Event) -> None:
# Handle input events that haven't been forwarded
# If the event has been forwarded it may have bubbled up back to the App
if isinstance(event, events.Compose):
screen: Screen[Any] = self.get_default_screen()
self._register(self, screen)
self._screen_stack.append(screen)
screen.post_message(events.ScreenResume())
await self._init_mode(self._current_mode)
await super().on_event(event)

elif isinstance(event, events.InputEvent) and not event.is_forwarded:
Expand Down
2 changes: 1 addition & 1 deletion src/textual/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ def __rich_repr__(self) -> rich.repr.Result:
yield g
yield b
yield "a", a, 1.0
yield "ansi", ansi
yield "ansi", ansi, None

def with_alpha(self, alpha: float) -> Color:
"""Create a new color with the given alpha.
Expand Down
Loading

0 comments on commit b75269f

Please sign in to comment.