Skip to content

WIP new option list #5510

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

Merged
merged 21 commits into from
Feb 12, 2025
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed scrollbars ignoring background opacity https://github.com/Textualize/textual/issues/5458
- Fixed `Header` icon showing command palette tooltip when disabled https://github.com/Textualize/textual/pull/5427

### Changed

- OptionList no longer supports `Separator`, a separator may be specified with `None`

### Removed

- Removed `wrap` argument from OptionList (use CSS `text-wrap: nowrap; text-overflow: ellipses`)
- Removed `tooltip` argument from OptionList. Use `tooltip` attribute or `with_tooltip(...)` method.

## [1.0.0] - 2024-12-12

Expand Down
16 changes: 8 additions & 8 deletions docs/examples/widgets/option_list_options.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, OptionList
from textual.widgets.option_list import Option, Separator
from textual.widgets.option_list import Option


class OptionListApp(App[None]):
Expand All @@ -11,22 +11,22 @@ def compose(self) -> ComposeResult:
yield OptionList(
Option("Aerilon", id="aer"),
Option("Aquaria", id="aqu"),
Separator(),
None,
Option("Canceron", id="can"),
Option("Caprica", id="cap", disabled=True),
Separator(),
None,
Option("Gemenon", id="gem"),
Separator(),
None,
Option("Leonis", id="leo"),
Option("Libran", id="lib"),
Separator(),
None,
Option("Picon", id="pic"),
Separator(),
None,
Option("Sagittaron", id="sag"),
Option("Scorpia", id="sco"),
Separator(),
None,
Option("Tauron", id="tau"),
Separator(),
None,
Option("Virgon", id="vir"),
)
yield Footer()
Expand Down
21 changes: 14 additions & 7 deletions src/textual/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@

import rich.repr
from rich.align import Align
from rich.style import Style
from rich.text import Text
from typing_extensions import Final, TypeAlias

Expand All @@ -48,7 +47,7 @@
from textual.message import Message
from textual.reactive import var
from textual.screen import Screen, SystemModalScreen
from textual.style import Style as VisualStyle
from textual.style import Style
from textual.timer import Timer
from textual.types import IgnoreReturnCallbackType
from textual.visual import VisualType
Expand Down Expand Up @@ -190,6 +189,10 @@ def __init__(self, screen: Screen[Any], match_style: Style | None = None) -> Non
Args:
screen: A reference to the active screen.
"""
if match_style is not None:
assert isinstance(
match_style, Style
), "match_style must be a Visual style (from textual.style import Style)"
self.__screen = screen
self.__match_style = match_style
self._init_task: Task | None = None
Expand Down Expand Up @@ -229,7 +232,9 @@ def matcher(self, user_input: str, case_sensitive: bool = False) -> Matcher:
A [fuzzy matcher][textual.fuzzy.Matcher] object for matching against candidate hits.
"""
return Matcher(
user_input, match_style=self.match_style, case_sensitive=case_sensitive
user_input,
match_style=self.match_style,
case_sensitive=case_sensitive,
)

def _post_init(self) -> None:
Expand Down Expand Up @@ -416,6 +421,9 @@ def __init__(
self.hit = hit
"""The details of the hit associated with the option."""

def __hash__(self) -> int:
return id(self)

def __lt__(self, other: object) -> bool:
if isinstance(other, Command):
return self.hit < other.hit
Expand Down Expand Up @@ -803,9 +811,7 @@ def _on_mount(self, _: Mount) -> None:
self.app.post_message(CommandPalette.Opened())
self._calling_screen = self.app.screen_stack[-2]

match_style = self.get_component_rich_style(
"command-palette--highlight", partial=True
)
match_style = self.get_visual_style("command-palette--highlight", partial=True)

assert self._calling_screen is not None
self._providers = [
Expand Down Expand Up @@ -1105,9 +1111,10 @@ def build_prompt() -> Iterable[Content]:
yield Content.from_rich_text(hit.prompt)
else:
yield Content.from_markup(hit.prompt)

# Optional help text
if hit.help:
help_style = VisualStyle.from_styles(
help_style = Style.from_styles(
self.get_component_styles("command-palette--help-text")
)
yield Content.from_markup(hit.help).stylize_before(help_style)
Expand Down
33 changes: 18 additions & 15 deletions src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ def get_height(self, rules: RulesMap, width: int) -> int:
lines = self.without_spans._wrap_and_format(
width,
overflow=rules.get("text_overflow", "fold"),
no_wrap=rules.get("text_wrap") == "nowrap",
no_wrap=rules.get("text_wrap", "wrap") == "nowrap",
)
return len(lines)

Expand Down Expand Up @@ -442,17 +442,17 @@ def get_span(y: int) -> tuple[int, int] | None:

line = line.expand_tabs(tab_size)

if no_wrap and overflow == "fold":
cuts = list(range(0, line.cell_length, width))[1:]
new_lines = [
_FormattedLine(line, width, y=y, align=align)
for line in line.divide(cuts)
]
elif no_wrap:
if overflow == "ellipsis" and no_wrap:
line = line.truncate(width, ellipsis=True)
content_line = _FormattedLine(line, width, y=y, align=align)
new_lines = [content_line]
if no_wrap:
if overflow == "fold":
cuts = list(range(0, line.cell_length, width))[1:]
new_lines = [
_FormattedLine(line, width, y=y, align=align)
for line in line.divide(cuts)
]
else:
line = line.truncate(width, ellipsis=overflow == "ellipsis")
content_line = _FormattedLine(line, width, y=y, align=align)
new_lines = [content_line]
else:
content_line = _FormattedLine(line, width, y=y, align=align)
offsets = divide_line(line.plain, width, fold=overflow == "fold")
Expand Down Expand Up @@ -495,6 +495,7 @@ def render_strips(
Returns:
An list of Strips.
"""

if not width:
return []

Expand Down Expand Up @@ -948,7 +949,7 @@ def render(
self,
base_style: Style = Style.null(),
end: str = "\n",
parse_style: Callable[[str], Style] | None = None,
parse_style: Callable[[str | Style], Style] | None = None,
) -> Iterable[tuple[str, Style]]:
"""Render Content in to an iterable of strings and styles.

Expand All @@ -971,11 +972,13 @@ def render(
yield end, base_style
return

get_style: Callable[[str], Style]
get_style: Callable[[str | Style], Style]
if parse_style is None:

def get_style(style: str, /) -> Style:
def get_style(style: str | Style) -> Style:
"""The default get_style method."""
if isinstance(style, Style):
return style
try:
visual_style = Style.parse(style)
except Exception:
Expand Down
4 changes: 3 additions & 1 deletion src/textual/css/stylesheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def set_variables(self, variables: dict[str, str]) -> None:
self._parse_cache.clear()
self._style_parse_cache.clear()

def parse_style(self, style_text: str) -> Style:
def parse_style(self, style_text: str | Style) -> Style:
"""Parse a (visual) Style.

Args:
Expand All @@ -229,6 +229,8 @@ def parse_style(self, style_text: str) -> Style:
Returns:
New Style instance.
"""
if isinstance(style_text, Style):
return style_text
if style_text in self._style_parse_cache:
return self._style_parse_cache[style_text]
style = parse_style(style_text)
Expand Down
15 changes: 8 additions & 7 deletions src/textual/fuzzy.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
from typing import Iterable, NamedTuple

import rich.repr
from rich.style import Style
from rich.text import Text

from textual.content import Content
from textual.visual import Style


class _Search(NamedTuple):
Expand Down Expand Up @@ -203,7 +204,7 @@ def match(self, candidate: str) -> float:
"""
return self.fuzzy_search.match(self.query, candidate)[0]

def highlight(self, candidate: str) -> Text:
def highlight(self, candidate: str) -> Content:
"""Highlight the candidate with the fuzzy match.

Args:
Expand All @@ -212,11 +213,11 @@ def highlight(self, candidate: str) -> Text:
Returns:
A [rich.text.Text][`Text`] object with highlighted matches.
"""
text = Text.from_markup(candidate)
content = Content.from_markup(candidate)
score, offsets = self.fuzzy_search.match(self.query, candidate)
if not score:
return text
return content
for offset in offsets:
if not candidate[offset].isspace():
text.stylize(self._match_style, offset, offset + 1)
return text
content = content.stylize(self._match_style, offset, offset + 1)
return content
4 changes: 1 addition & 3 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -962,9 +962,7 @@ def _update_focus_styles(
widgets.update(widget.walk_children(with_self=True))
break
if widgets:
self.app.stylesheet.update_nodes(
[widget for widget in widgets if widget._has_focus_within], animate=True
)
self.app.stylesheet.update_nodes(widgets, animate=True)

def set_focus(
self,
Expand Down
15 changes: 14 additions & 1 deletion src/textual/strip.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from __future__ import annotations

from itertools import chain
from typing import Iterable, Iterator, Sequence
from typing import Any, Iterable, Iterator, Sequence

import rich.repr
from rich.cells import cell_len, set_cell_size
Expand Down Expand Up @@ -599,6 +599,19 @@ def apply_style(self, style: Style) -> Strip:
self._style_cache[style] = styled_strip
return styled_strip

def apply_meta(self, meta: dict[str, Any]) -> Strip:
"""Apply meta to all segments.

Args:
meta: A dict of meta information.

Returns:
A new strip.

"""
meta_style = Style.from_meta(meta)
return self.apply_style(meta_style)

def _apply_link_style(self, link_style: Style) -> Strip:
segments = self._segments
_Segment = Segment
Expand Down
9 changes: 7 additions & 2 deletions src/textual/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,14 @@ def __add__(self, other: object | None) -> Style:
new_style = Style(
(
other.background
if self.background is None
if (self.background is None or self.background.a == 0)
else self.background + other.background
),
self.foreground if other.foreground is None else other.foreground,
(
self.foreground
if (other.foreground is None or other.foreground.a == 0)
else other.foreground
),
self.bold if other.bold is None else other.bold,
self.dim if other.dim is None else other.dim,
self.italic if other.italic is None else other.italic,
Expand Down Expand Up @@ -368,6 +372,7 @@ def without_color(self) -> Style:
bold=self.bold,
dim=self.dim,
italic=self.italic,
underline=self.underline,
reverse=self.reverse,
strike=self.strike,
link=self.link,
Expand Down
2 changes: 0 additions & 2 deletions src/textual/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
from textual.widgets._input import InputValidationOn
from textual.widgets._option_list import (
DuplicateID,
NewOptionListContent,
OptionDoesNotExist,
OptionListContent,
)
Expand All @@ -41,7 +40,6 @@
"IgnoreReturnCallbackType",
"InputValidationOn",
"MessageTarget",
"NewOptionListContent",
"NoActiveAppError",
"NoSelection",
"OptionDoesNotExist",
Expand Down
Loading
Loading