Merge pull request #2490 from Textualize/messages-control

Add control to widget messages.
This commit is contained in:
Rodrigo Girão Serrão
2023-05-08 11:26:31 +01:00
committed by GitHub
9 changed files with 246 additions and 69 deletions

View File

@@ -35,6 +35,30 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510 - Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510
- Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510 - Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510
### Added
- Markdown document sub-widgets now reference the container document
- Table of contents of a markdown document now references the document
- Added the `control` property to messages
- `DirectoryTree.FileSelected`
- `ListView`
- `Highlighted`
- `Selected`
- `Markdown`
- `TableOfContentsUpdated`
- `TableOfContentsSelected`
- `LinkClicked`
- `OptionList`
- `OptionHighlighted`
- `OptionSelected`
- `RadioSet.Changed`
- `TabContent.TabActivated`
- `Tree`
- `NodeSelected`
- `NodeHighlighted`
- `NodeExpanded`
- `NodeCollapsed`
## [0.23.0] - 2023-05-03 ## [0.23.0] - 2023-05-03
### Fixed ### Fixed

View File

@@ -90,18 +90,7 @@ tables](https://rich.readthedocs.io/en/latest/tables.html):
- [OptionList.OptionHighlight][textual.widgets.OptionList.OptionHighlighted] - [OptionList.OptionHighlight][textual.widgets.OptionList.OptionHighlighted]
- [OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected] - [OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected]
Both of the messages above inherit from this common base, which makes Both of the messages above inherit from the common base [`OptionList`][textual.widgets.OptionList.OptionMessage], so refer to its documentation to see what attributes are available.
available the following properties relating to the `OptionList` and the
related `Option`:
### Common message properties
Both of the above messages provide the following properties:
#### ::: textual.widgets.OptionList.OptionMessage.option
#### ::: textual.widgets.OptionList.OptionMessage.option_id
#### ::: textual.widgets.OptionList.OptionMessage.option_index
#### ::: textual.widgets.OptionList.OptionMessage.option_list
## Bindings ## Bindings

View File

@@ -64,7 +64,9 @@ class DirectoryTree(Tree[DirEntry]):
`DirectoryTree` or in a parent widget in the DOM. `DirectoryTree` or in a parent widget in the DOM.
""" """
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None: def __init__(
self, tree: DirectoryTree, node: TreeNode[DirEntry], path: Path
) -> None:
"""Initialise the FileSelected object. """Initialise the FileSelected object.
Args: Args:
@@ -72,11 +74,22 @@ class DirectoryTree(Tree[DirEntry]):
path: The path of the file that was selected. path: The path of the file that was selected.
""" """
super().__init__() super().__init__()
self.tree: DirectoryTree = tree
"""The `DirectoryTree` that had a file selected."""
self.node: TreeNode[DirEntry] = node self.node: TreeNode[DirEntry] = node
"""The tree node of the file that was selected.""" """The tree node of the file that was selected."""
self.path: Path = path self.path: Path = path
"""The path of the file that was selected.""" """The path of the file that was selected."""
@property
def control(self) -> DirectoryTree:
"""The `DirectoryTree` that had a file selected.
This is an alias for [`FileSelected.tree`][textual.widgets.DirectoryTree.FileSelected.tree]
which is used by the [`on`][textual.on] decorator.
"""
return self.tree
path: var[str | Path] = var["str | Path"](Path("."), init=False) path: var[str | Path] = var["str | Path"](Path("."), init=False)
"""The path that is the root of the directory tree. """The path that is the root of the directory tree.

View File

@@ -44,30 +44,46 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
Highlighted item is controlled using up/down keys. Highlighted item is controlled using up/down keys.
Can be handled using `on_list_view_highlighted` in a subclass of `ListView` Can be handled using `on_list_view_highlighted` in a subclass of `ListView`
or in a parent widget in the DOM. or in a parent widget in the DOM.
Attributes:
item: The highlighted item, if there is one highlighted.
""" """
def __init__(self, list_view: ListView, item: ListItem | None) -> None: def __init__(self, list_view: ListView, item: ListItem | None) -> None:
super().__init__() super().__init__()
self.list_view = list_view self.list_view: ListView = list_view
"""The view that contains the item highlighted."""
self.item: ListItem | None = item self.item: ListItem | None = item
"""The highlighted item, if there is one highlighted."""
@property
def control(self) -> ListView:
"""The view that contains the item highlighted.
This is an alias for [`Highlighted.list_view`][textual.widgets.ListView.Highlighted.list_view]
and is used by the [`on`][textual.on] decorator.
"""
return self.list_view
class Selected(Message, bubble=True): class Selected(Message, bubble=True):
"""Posted when a list item is selected, e.g. when you press the enter key on it. """Posted when a list item is selected, e.g. when you press the enter key on it.
Can be handled using `on_list_view_selected` in a subclass of `ListView` or in Can be handled using `on_list_view_selected` in a subclass of `ListView` or in
a parent widget in the DOM. a parent widget in the DOM.
Attributes:
item: The selected item.
""" """
def __init__(self, list_view: ListView, item: ListItem) -> None: def __init__(self, list_view: ListView, item: ListItem) -> None:
super().__init__() super().__init__()
self.list_view = list_view self.list_view: ListView = list_view
"""The view that contains the item selected."""
self.item: ListItem = item self.item: ListItem = item
"""The selected item."""
@property
def control(self) -> ListView:
"""The view that contains the item selected.
This is an alias for [`Selected.list_view`][textual.widgets.ListView.Selected.list_view]
and is used by the [`on`][textual.on] decorator.
"""
return self.list_view
def __init__( def __init__(
self, self,

View File

@@ -88,7 +88,9 @@ class MarkdownBlock(Static):
} }
""" """
def __init__(self, *args, **kwargs) -> None: def __init__(self, markdown: Markdown, *args, **kwargs) -> None:
self._markdown: Markdown = markdown
"""A reference to the Markdown document that contains this block."""
self._text = Text() self._text = Text()
self._blocks: list[MarkdownBlock] = [] self._blocks: list[MarkdownBlock] = []
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -103,7 +105,7 @@ class MarkdownBlock(Static):
async def action_link(self, href: str) -> None: async def action_link(self, href: str) -> None:
"""Called on link click.""" """Called on link click."""
self.post_message(Markdown.LinkClicked(href)) self.post_message(Markdown.LinkClicked(self._markdown, href))
class MarkdownHeader(MarkdownBlock): class MarkdownHeader(MarkdownBlock):
@@ -453,9 +455,9 @@ class MarkdownListItem(MarkdownBlock):
} }
""" """
def __init__(self, bullet: str) -> None: def __init__(self, markdown: Markdown, bullet: str) -> None:
self.bullet = bullet self.bullet = bullet
super().__init__() super().__init__(markdown)
class MarkdownOrderedListItem(MarkdownListItem): class MarkdownOrderedListItem(MarkdownListItem):
@@ -484,10 +486,10 @@ class MarkdownFence(MarkdownBlock):
} }
""" """
def __init__(self, code: str, lexer: str) -> None: def __init__(self, markdown: Markdown, code: str, lexer: str) -> None:
self.code = code self.code = code
self.lexer = lexer self.lexer = lexer
super().__init__() super().__init__(markdown)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Static( yield Static(
@@ -565,27 +567,62 @@ class Markdown(Widget):
class TableOfContentsUpdated(Message, bubble=True): class TableOfContentsUpdated(Message, bubble=True):
"""The table of contents was updated.""" """The table of contents was updated."""
def __init__(self, table_of_contents: TableOfContentsType) -> None: def __init__(
self, markdown: Markdown, table_of_contents: TableOfContentsType
) -> None:
super().__init__() super().__init__()
self.markdown: Markdown = markdown
"""The `Markdown` widget associated with the table of contents."""
self.table_of_contents: TableOfContentsType = table_of_contents self.table_of_contents: TableOfContentsType = table_of_contents
"""Table of contents.""" """Table of contents."""
@property
def control(self) -> Markdown:
"""The `Markdown` widget associated with the table of contents.
This is an alias for [`TableOfContentsUpdated.markdown`][textual.widgets.Markdown.TableOfContentsSelected.markdown]
and is used by the [`on`][textual.on] decorator.
"""
return self.markdown
class TableOfContentsSelected(Message, bubble=True): class TableOfContentsSelected(Message, bubble=True):
"""An item in the TOC was selected.""" """An item in the TOC was selected."""
def __init__(self, block_id: str) -> None: def __init__(self, markdown: Markdown, block_id: str) -> None:
super().__init__() super().__init__()
self.block_id = block_id self.markdown: Markdown = markdown
"""The `Markdown` widget where the selected item is."""
self.block_id: str = block_id
"""ID of the block that was selected.""" """ID of the block that was selected."""
@property
def control(self) -> Markdown:
"""The `Markdown` widget where the selected item is.
This is an alias for [`TableOfContentsSelected.markdown`][textual.widgets.Markdown.TableOfContentsSelected.markdown]
and is used by the [`on`][textual.on] decorator.
"""
return self.markdown
class LinkClicked(Message, bubble=True): class LinkClicked(Message, bubble=True):
"""A link in the document was clicked.""" """A link in the document was clicked."""
def __init__(self, href: str) -> None: def __init__(self, markdown: Markdown, href: str) -> None:
super().__init__() super().__init__()
self.markdown: Markdown = markdown
"""The `Markdown` widget containing the link clicked."""
self.href: str = href self.href: str = href
"""The link that was selected.""" """The link that was selected."""
@property
def control(self) -> Markdown:
"""The `Markdown` widget containing the link clicked.
This is an alias for [`LinkClicked.markdown`][textual.widgets.Markdown.LinkClicked.markdown]
and is used by the [`on`][textual.on] decorator.
"""
return self.markdown
def _on_mount(self, _: Mount) -> None: def _on_mount(self, _: Mount) -> None:
if self._markdown is not None: if self._markdown is not None:
self.update(self._markdown) self.update(self._markdown)
@@ -629,20 +666,20 @@ class Markdown(Widget):
for token in parser.parse(markdown): for token in parser.parse(markdown):
if token.type == "heading_open": if token.type == "heading_open":
block_id += 1 block_id += 1
stack.append(HEADINGS[token.tag](id=f"block{block_id}")) stack.append(HEADINGS[token.tag](self, id=f"block{block_id}"))
elif token.type == "hr": elif token.type == "hr":
output.append(MarkdownHorizontalRule()) output.append(MarkdownHorizontalRule(self))
elif token.type == "paragraph_open": elif token.type == "paragraph_open":
stack.append(MarkdownParagraph()) stack.append(MarkdownParagraph(self))
elif token.type == "blockquote_open": elif token.type == "blockquote_open":
stack.append(MarkdownBlockQuote()) stack.append(MarkdownBlockQuote(self))
elif token.type == "bullet_list_open": elif token.type == "bullet_list_open":
stack.append(MarkdownBulletList()) stack.append(MarkdownBulletList(self))
elif token.type == "ordered_list_open": elif token.type == "ordered_list_open":
stack.append(MarkdownOrderedList()) stack.append(MarkdownOrderedList(self))
elif token.type == "list_item_open": elif token.type == "list_item_open":
if token.info: if token.info:
stack.append(MarkdownOrderedListItem(token.info)) stack.append(MarkdownOrderedListItem(self, token.info))
else: else:
item_count = sum( item_count = sum(
1 1
@@ -651,22 +688,23 @@ class Markdown(Widget):
) )
stack.append( stack.append(
MarkdownUnorderedListItem( MarkdownUnorderedListItem(
self.BULLETS[item_count % len(self.BULLETS)] self,
self.BULLETS[item_count % len(self.BULLETS)],
) )
) )
elif token.type == "table_open": elif token.type == "table_open":
stack.append(MarkdownTable()) stack.append(MarkdownTable(self))
elif token.type == "tbody_open": elif token.type == "tbody_open":
stack.append(MarkdownTBody()) stack.append(MarkdownTBody(self))
elif token.type == "thead_open": elif token.type == "thead_open":
stack.append(MarkdownTHead()) stack.append(MarkdownTHead(self))
elif token.type == "tr_open": elif token.type == "tr_open":
stack.append(MarkdownTR()) stack.append(MarkdownTR(self))
elif token.type == "th_open": elif token.type == "th_open":
stack.append(MarkdownTH()) stack.append(MarkdownTH(self))
elif token.type == "td_open": elif token.type == "td_open":
stack.append(MarkdownTD()) stack.append(MarkdownTD(self))
elif token.type.endswith("_close"): elif token.type.endswith("_close"):
block = stack.pop() block = stack.pop()
if token.type == "heading_close": if token.type == "heading_close":
@@ -742,12 +780,13 @@ class Markdown(Widget):
elif token.type == "fence": elif token.type == "fence":
output.append( output.append(
MarkdownFence( MarkdownFence(
self,
token.content.rstrip(), token.content.rstrip(),
token.info, token.info,
) )
) )
self.post_message(Markdown.TableOfContentsUpdated(table_of_contents)) self.post_message(Markdown.TableOfContentsUpdated(self, table_of_contents))
with self.app.batch_update(): with self.app.batch_update():
self.query("MarkdownBlock").remove() self.query("MarkdownBlock").remove()
self.mount_all(output) self.mount_all(output)
@@ -768,6 +807,27 @@ class MarkdownTableOfContents(Widget, can_focus_children=True):
table_of_contents = reactive["TableOfContentsType | None"](None, init=False) table_of_contents = reactive["TableOfContentsType | None"](None, init=False)
def __init__(
self,
markdown: Markdown,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""Initialize a table of contents.
Args:
markdown: The Markdown document associated with this table of contents.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes for the widget.
disabled: Whether the widget is disabled or not.
"""
self.markdown = markdown
"""The Markdown document associated with this table of contents."""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
tree: Tree = Tree("TOC") tree: Tree = Tree("TOC")
tree.show_root = False tree.show_root = False
@@ -804,8 +864,9 @@ class MarkdownTableOfContents(Widget, can_focus_children=True):
node_data = message.node.data node_data = message.node.data
if node_data is not None: if node_data is not None:
await self._post_message( await self._post_message(
Markdown.TableOfContentsSelected(node_data["block_id"]) Markdown.TableOfContentsSelected(self.markdown, node_data["block_id"])
) )
message.stop()
class MarkdownViewer(VerticalScroll, can_focus=True, can_focus_children=True): class MarkdownViewer(VerticalScroll, can_focus=True, can_focus_children=True):
@@ -896,8 +957,9 @@ class MarkdownViewer(VerticalScroll, can_focus=True, can_focus_children=True):
self.set_class(show_table_of_contents, "-show-table-of-contents") self.set_class(show_table_of_contents, "-show-table-of-contents")
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield MarkdownTableOfContents() markdown = Markdown(parser_factory=self._parser_factory)
yield Markdown(parser_factory=self._parser_factory) yield MarkdownTableOfContents(markdown)
yield markdown
def _on_markdown_table_of_contents_updated( def _on_markdown_table_of_contents_updated(
self, message: Markdown.TableOfContentsUpdated self, message: Markdown.TableOfContentsUpdated

View File

@@ -255,6 +255,15 @@ class OptionList(ScrollView, can_focus=True):
self.option_index: int = index self.option_index: int = index
"""The index of the option that the message relates to.""" """The index of the option that the message relates to."""
@property
def control(self) -> OptionList:
"""The option list that sent the message.
This is an alias for [`OptionMessage.option_list`][textual.widgets.OptionList.OptionMessage.option_list]
and is used by the [`on`][textual.on] decorator.
"""
return self.option_list
def __rich_repr__(self) -> Result: def __rich_repr__(self) -> Result:
yield "option_list", self.option_list yield "option_list", self.option_list
yield "option", self.option yield "option", self.option

View File

@@ -92,6 +92,15 @@ class RadioSet(Container, can_focus=True, can_focus_children=False):
self.index = radio_set.pressed_index self.index = radio_set.pressed_index
"""The index of the [`RadioButton`][textual.widgets.RadioButton] that was pressed to make the change.""" """The index of the [`RadioButton`][textual.widgets.RadioButton] that was pressed to make the change."""
@property
def control(self) -> RadioSet:
"""A reference to the [`RadioSet`][textual.widgets.RadioSet] that was changed.
This is an alias for [`Changed.radio_set`][textual.widgets.RadioSet.Changed.radio_set]
and is used by the [`on`][textual.on] decorator.
"""
return self.radio_set
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield "radio_set", self.radio_set yield "radio_set", self.radio_set
yield "pressed", self.pressed yield "pressed", self.pressed

View File

@@ -96,9 +96,20 @@ class TabbedContent(Widget):
tab: The Tab widget that was selected (contains the tab label). tab: The Tab widget that was selected (contains the tab label).
""" """
self.tabbed_content = tabbed_content self.tabbed_content = tabbed_content
"""The `TabbedContent` widget that contains the tab activated."""
self.tab = tab self.tab = tab
"""The `Tab` widget that was selected (contains the tab label)."""
super().__init__() super().__init__()
@property
def control(self) -> TabbedContent:
"""The `TabbedContent` widget that contains the tab activated.
This is an alias for [`TabActivated.tabbed_content`][textual.widgets.TabbedContent.TabActivated.tabbed_content]
and is used by the [`on`][textual.on] decorator.
"""
return self.tabbed_content
def __rich_repr__(self) -> Result: def __rich_repr__(self) -> Result:
yield self.tabbed_content yield self.tabbed_content
yield self.tab yield self.tab

View File

@@ -510,57 +510,101 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
Can be handled using `on_tree_node_collapsed` in a subclass of `Tree` or in a Can be handled using `on_tree_node_collapsed` in a subclass of `Tree` or in a
parent node in the DOM. parent node in the DOM.
Attributes:
node: The node that was collapsed.
""" """
def __init__(self, node: TreeNode[EventTreeDataType]) -> None: def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
self.node: TreeNode[EventTreeDataType] = node self.node: TreeNode[EventTreeDataType] = node
"""The node that was collapsed."""
super().__init__() super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeCollapsed.tree`][textual.widgets.Tree.NodeCollapsed.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True): class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is expanded. """Event sent when a node is expanded.
Can be handled using `on_tree_node_expanded` in a subclass of `Tree` or in a Can be handled using `on_tree_node_expanded` in a subclass of `Tree` or in a
parent node in the DOM. parent node in the DOM.
Attributes:
node: The node that was expanded.
""" """
def __init__(self, node: TreeNode[EventTreeDataType]) -> None: def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
self.node: TreeNode[EventTreeDataType] = node self.node: TreeNode[EventTreeDataType] = node
"""The node that was expanded."""
super().__init__() super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeExpanded.tree`][textual.widgets.Tree.NodeExpanded.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
class NodeHighlighted(Generic[EventTreeDataType], Message, bubble=True): class NodeHighlighted(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is highlighted. """Event sent when a node is highlighted.
Can be handled using `on_tree_node_highlighted` in a subclass of `Tree` or in a Can be handled using `on_tree_node_highlighted` in a subclass of `Tree` or in a
parent node in the DOM. parent node in the DOM.
Attributes:
node: The node that was highlighted.
""" """
def __init__(self, node: TreeNode[EventTreeDataType]) -> None: def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
self.node: TreeNode[EventTreeDataType] = node self.node: TreeNode[EventTreeDataType] = node
"""The node that was highlighted."""
super().__init__() super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeHighlighted.tree`][textual.widgets.Tree.NodeHighlighted.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
class NodeSelected(Generic[EventTreeDataType], Message, bubble=True): class NodeSelected(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is selected. """Event sent when a node is selected.
Can be handled using `on_tree_node_selected` in a subclass of `Tree` or in a Can be handled using `on_tree_node_selected` in a subclass of `Tree` or in a
parent node in the DOM. parent node in the DOM.
Attributes:
node: The node that was selected.
""" """
def __init__(self, node: TreeNode[EventTreeDataType]) -> None: def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
self.node: TreeNode[EventTreeDataType] = node self.node: TreeNode[EventTreeDataType] = node
"""The node that was selected."""
super().__init__() super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeSelected.tree`][textual.widgets.Tree.NodeSelected.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
def __init__( def __init__(
self, self,
label: TextType, label: TextType,
@@ -859,7 +903,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
node._selected = True node._selected = True
self._cursor_node = node self._cursor_node = node
if previous_node != node: if previous_node != node:
self.post_message(self.NodeHighlighted(node)) self.post_message(self.NodeHighlighted(self, node))
else: else:
self._cursor_node = None self._cursor_node = None
@@ -1113,10 +1157,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
return return
if node.is_expanded: if node.is_expanded:
node.collapse() node.collapse()
self.post_message(self.NodeCollapsed(node)) self.post_message(self.NodeCollapsed(self, node))
else: else:
node.expand() node.expand()
self.post_message(self.NodeExpanded(node)) self.post_message(self.NodeExpanded(self, node))
async def _on_click(self, event: events.Click) -> None: async def _on_click(self, event: events.Click) -> None:
meta = event.style.meta meta = event.style.meta
@@ -1203,4 +1247,4 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
node = line.path[-1] node = line.path[-1]
if self.auto_expand: if self.auto_expand:
self._toggle_node(node) self._toggle_node(node)
self.post_message(self.NodeSelected(node)) self.post_message(self.NodeSelected(self, node))