link widget

This commit is contained in:
Will McGugan
2024-10-13 18:44:33 +01:00
parent ca4af0c41f
commit 6298acb2a2
22 changed files with 229 additions and 49 deletions

View File

@@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Grid will now size children to the maximum height of a row
### Added
- Added Link widget
## [0.83.0] - 2024-10-10
### Added

6
docs/api/layout.md Normal file
View File

@@ -0,0 +1,6 @@
---
title: "textual.layout"
---
::: textual.layout

View File

@@ -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()

View File

@@ -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).

61
docs/widgets/link.md Normal file
View File

@@ -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

View File

@@ -151,6 +151,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"
@@ -194,6 +195,7 @@ nav:
- "api/filter.md"
- "api/fuzzy_matcher.md"
- "api/geometry.md"
- "api/layout.md"
- "api/lazy.md"
- "api/logger.md"
- "api/logging.md"

View File

@@ -5,9 +5,9 @@ from fractions import Fraction
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

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
from typing import ClassVar
from textual.binding import Binding, BindingType
from textual.layout import Layout
from textual.layouts.grid import GridLayout
from textual.reactive import reactive
from textual.widget import Widget
@@ -158,6 +159,19 @@ class Grid(Widget, inherit_bindings=False):
}
"""
class ItemGrid(Widget, inherit_bindings=False):
"""A container with grid layout."""
DEFAULT_CSS = """
ItemGrid {
width: 1fr;
height: 1fr;
layout: grid;
}
"""
stretch_height: reactive[bool] = reactive(True)
min_column_width: reactive[int | None] = reactive(None, layout=True)
def __init__(
@@ -168,6 +182,7 @@ class Grid(Widget, inherit_bindings=False):
classes: str | None = None,
disabled: bool = False,
min_column_width: int | None = None,
stretch_height: bool = True,
) -> None:
"""Initialize a Widget.
@@ -181,10 +196,10 @@ class Grid(Widget, inherit_bindings=False):
super().__init__(
*children, name=name, id=id, classes=classes, disabled=disabled
)
self.min_column_width = min_column_width
self.set_reactive(ItemGrid.stretch_height, stretch_height)
self.set_reactive(ItemGrid.min_column_width, min_column_width)
def pre_layout(self) -> None:
if isinstance(self.layout, GridLayout):
if self.layout.min_column_width != self.min_column_width:
self.layout.min_column_width = self.min_column_width
self.refresh(layout=True)
def pre_layout(self, layout: Layout) -> None:
if isinstance(layout, GridLayout):
layout.stretch_height = self.stretch_height
layout.min_column_width = self.min_column_width

View File

@@ -57,7 +57,7 @@ from textual.geometry import NULL_SPACING, Spacing, SpacingDimensions, clamp
if TYPE_CHECKING:
from textual.canvas import CanvasLineType
from textual._layout import Layout
from textual.layout import Layout
from textual.css.styles import StylesBase
from textual.css.types import AlignHorizontal, AlignVertical, DockEdge, EdgeType

View File

@@ -69,9 +69,9 @@ from textual.css.types import (
from textual.geometry import Offset, Spacing
if TYPE_CHECKING:
from textual._layout import Layout
from textual.css.types import CSSLocation
from textual.dom import DOMNode
from textual.layout import Layout
class RulesMap(TypedDict, total=False):

View File

@@ -3,9 +3,9 @@ from dataclasses import dataclass
from textual import events, on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Grid, Horizontal, Vertical
from textual.containers import Horizontal, ItemGrid, Vertical
from textual.demo2.page import PageScreen
from textual.widgets import Footer, Label, Static
from textual.widgets import Footer, Label, Link, Static
@dataclass
@@ -113,27 +113,7 @@ Chat with Claude 3, ChatGPT, and local models like Llama 3, Phi 3, Mistral and G
]
class Link(Label):
"""The link in the Project widget."""
DEFAULT_CSS = """
Link {
color: $accent;
text-style: underline;
&:hover { text-style: reverse;}
}
"""
def __init__(self, url: str) -> None:
super().__init__(url)
self.url = url
self.tooltip = "Click to open Repository URL"
def on_click(self) -> None:
self.app.open_url(self.url)
class Project(Vertical, can_focus=True):
class Project(Vertical, can_focus=True, can_focus_children=False):
ALLOW_MAXIMIZE = True
DEFAULT_CSS = """
Project {
@@ -143,7 +123,7 @@ class Project(Vertical, can_focus=True):
border: tall transparent;
opacity: 0.8;
box-sizing: border-box;
&:focus-within {
&:focus {
border: tall $accent;
background: $primary 40%;
opacity: 1.0;
@@ -185,7 +165,7 @@ class Project(Vertical, can_focus=True):
yield Label(info.title, id="title")
yield Label(f"{info.stars}", classes="stars")
yield Label(info.author, id="author")
yield Link(info.url)
yield Link(info.url, tooltip="Click to open project repository")
yield Static(info.description, classes="description")
@on(events.Enter)
@@ -201,7 +181,7 @@ class Project(Vertical, can_focus=True):
class ProjectsScreen(PageScreen):
DEFAULT_CSS = """
ProjectsScreen {
Grid {
ItemGrid {
margin: 1 2;
padding: 1 2;
background: $boost;
@@ -216,7 +196,7 @@ class ProjectsScreen(PageScreen):
"""
def compose(self) -> ComposeResult:
with Grid(min_column_width=40):
with ItemGrid(min_column_width=40):
for project in PROJECTS:
yield Project(project)
yield Footer()

View File

@@ -19,6 +19,8 @@ ArrangeResult: TypeAlias = "list[WidgetPlacement]"
@dataclass
class DockArrangeResult:
"""Result of [Layout.arrange][textual.layout.Layout.arrange]."""
placements: list[WidgetPlacement]
"""A `WidgetPlacement` for every widget to describe its location on screen."""
widgets: set[Widget]
@@ -125,7 +127,7 @@ class WidgetPlacement(NamedTuple):
class Layout(ABC):
"""Responsible for arranging Widgets in a view and rendering them."""
"""Base class of the object responsible for arranging Widgets within a container."""
name: ClassVar[str] = ""

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from textual._layout import Layout
from textual.layout import Layout
from textual.layouts.grid import GridLayout
from textual.layouts.horizontal import HorizontalLayout
from textual.layouts.vertical import VerticalLayout

View File

@@ -3,10 +3,10 @@ from __future__ import annotations
from fractions import Fraction
from typing import TYPE_CHECKING, Iterable
from textual._layout import ArrangeResult, Layout, WidgetPlacement
from textual._resolve import resolve
from textual.css.scalar import Scalar
from textual.geometry import Region, Size, Spacing
from textual.layout import ArrangeResult, Layout, WidgetPlacement
if TYPE_CHECKING:
from textual.widget import Widget
@@ -19,11 +19,12 @@ class GridLayout(Layout):
def __init__(self) -> None:
self.min_column_width: int | None = None
self.stretch_height: bool = False
def arrange(
self, parent: Widget, children: list[Widget], size: Size
) -> ArrangeResult:
parent.pre_layout()
parent.pre_layout(self)
styles = parent.styles
row_scalars = styles.grid_rows or (
[Scalar.parse("1fr")] if size.height else [Scalar.parse("auto")]
@@ -272,9 +273,11 @@ class GridLayout(Layout):
Fraction(cell_size.width),
Fraction(cell_size.height),
)
if len(children) > 1:
if self.stretch_height and len(children) > 1:
height = (
height if height > cell_size.height else Fraction(cell_size.height)
height
if (height > cell_size.height)
else Fraction(cell_size.height)
)
region = (
Region(x, y, int(width + margin.width), int(height + margin.height))

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
from fractions import Fraction
from typing import TYPE_CHECKING
from textual._layout import ArrangeResult, Layout, WidgetPlacement
from textual._resolve import resolve_box_models
from textual.geometry import Region, Size
from textual.layout import ArrangeResult, Layout, WidgetPlacement
if TYPE_CHECKING:
from textual.geometry import Spacing

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
from fractions import Fraction
from typing import TYPE_CHECKING
from textual._layout import ArrangeResult, Layout, WidgetPlacement
from textual._resolve import resolve_box_models
from textual.geometry import Region, Size
from textual.layout import ArrangeResult, Layout, WidgetPlacement
if TYPE_CHECKING:
from textual.geometry import Spacing

View File

@@ -34,7 +34,6 @@ from textual._arrange import arrange
from textual._callback import invoke
from textual._compositor import Compositor, MapGeometry
from textual._context import active_message_pump, visible_screen_stack
from textual._layout import DockArrangeResult
from textual._path import (
CSSPathType,
_css_path_type_as_list,
@@ -50,6 +49,7 @@ from textual.dom import DOMNode
from textual.errors import NoWidget
from textual.geometry import Offset, Region, Size
from textual.keys import key_to_character
from textual.layout import DockArrangeResult
from textual.reactive import Reactive
from textual.renderables.background_screen import BackgroundScreen
from textual.renderables.blank import Blank
@@ -1205,8 +1205,8 @@ class Screen(Generic[ScreenResultType], Widget):
def _screen_resized(self, size: Size):
"""Called by App when the screen is resized."""
self._compositor_refresh()
self._refresh_layout(size)
self.refresh()
def _on_screen_resume(self) -> None:
"""Screen has resumed."""

View File

@@ -52,7 +52,6 @@ from textual._context import NoActiveAppError
from textual._debug import get_caller_file_and_line
from textual._dispatch_key import dispatch_key
from textual._easing import DEFAULT_SCROLL_EASING
from textual._layout import Layout
from textual._segment_tools import align_lines
from textual._styles_cache import StylesCache
from textual._types import AnimationLevel
@@ -76,6 +75,7 @@ from textual.geometry import (
Spacing,
clamp,
)
from textual.layout import Layout
from textual.layouts.vertical import VerticalLayout
from textual.message import Message
from textual.messages import CallbackType, Prune
@@ -2243,12 +2243,15 @@ class Widget(DOMNode):
return scrolled_x or scrolled_y
def pre_layout(self) -> None:
def pre_layout(self, layout: Layout) -> None:
"""This method id called prior to a layout operation.
Implement this method if you want to make updates that should impact
the layout.
Args:
layout: The [Layout][textual.layout.Layout] instance that will be used to arrange this widget's children.
"""
def scroll_to(

View File

@@ -23,6 +23,7 @@ if typing.TYPE_CHECKING:
from textual.widgets._input import Input
from textual.widgets._key_panel import KeyPanel
from textual.widgets._label import Label
from textual.widgets._link import Link
from textual.widgets._list_item import ListItem
from textual.widgets._list_view import ListView
from textual.widgets._loading_indicator import LoadingIndicator
@@ -63,6 +64,7 @@ __all__ = [
"Input",
"KeyPanel",
"Label",
"Link",
"ListItem",
"ListView",
"LoadingIndicator",

View File

@@ -12,6 +12,7 @@ from ._help_panel import HelpPanel as HelpPanel
from ._input import Input as Input
from ._key_panel import KeyPanel as KeyPanel
from ._label import Label as Label
from ._link import Link as Link
from ._list_item import ListItem as ListItem
from ._list_view import ListView as ListView
from ._loading_indicator import LoadingIndicator as LoadingIndicator

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from textual.binding import Binding
from textual.reactive import reactive
from textual.widgets import Static
class Link(Static, can_focus=True):
"""A simple, clickable link that opens a URL."""
DEFAULT_CSS = """
Link {
width: auto;
height: auto;
min-height: 1;
color: $accent;
text-style: underline;
&:hover { color: $accent-lighten-1; }
&:focus { text-style: bold reverse; }
}
"""
BINDINGS = [Binding("enter", "select", "Open link")]
"""
| Key(s) | Description |
| :- | :- |
| enter | Open the link in the browser. |
"""
text: reactive[str] = reactive("", layout=True)
url: reactive[str] = reactive("")
def __init__(
self,
text: str,
*,
url: str | None = None,
tooltip: str | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""A link widget.
Args:
text: Text of the link.
url: A URL to open, when clicked. If `None`, the `text` parameter will also be used as the url.
tooltip: Optional tooltip.
name: Name of widget.
id: ID of Widget.
classes: Space separated list of class names.
disabled: Whether the static is disabled or not.
"""
super().__init__(
text, name=name, id=id, classes=classes, disabled=disabled, markup=False
)
self.set_reactive(Link.text, text)
self.set_reactive(Link.url, text if url is None else url)
self.tooltip = tooltip
def watch_text(self, text: str) -> None:
self.update(text)
def on_click(self) -> None:
self.action_open_link()
def action_open_link(self) -> None:
if self.url:
self.app.open_url(self.url)

View File

@@ -1,9 +1,9 @@
import pytest
from textual._arrange import TOP_Z, arrange
from textual._layout import WidgetPlacement
from textual.app import App
from textual.geometry import Region, Size, Spacing
from textual.layout import WidgetPlacement
from textual.widget import Widget