mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
link widget
This commit is contained in:
@@ -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
6
docs/api/layout.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: "textual.layout"
|
||||
---
|
||||
|
||||
|
||||
::: textual.layout
|
||||
23
docs/examples/widgets/link.py
Normal file
23
docs/examples/widgets/link.py
Normal 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()
|
||||
@@ -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
61
docs/widgets/link.md
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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] = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
70
src/textual/widgets/_link.py
Normal file
70
src/textual/widgets/_link.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user