mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
style and components fixes
This commit is contained in:
@@ -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]):
|
||||
@@ -11,22 +11,22 @@ class OptionListApp(App[None]):
|
||||
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()
|
||||
|
||||
@@ -35,7 +35,6 @@ from typing import (
|
||||
|
||||
import rich.repr
|
||||
from rich.align import Align
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from typing_extensions import Final, TypeAlias
|
||||
|
||||
@@ -48,7 +47,7 @@ from textual.fuzzy import Matcher
|
||||
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
|
||||
@@ -190,6 +189,10 @@ class Provider(ABC):
|
||||
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 if given"
|
||||
self.__screen = screen
|
||||
self.__match_style = match_style
|
||||
self._init_task: Task | None = None
|
||||
@@ -228,8 +231,13 @@ class Provider(ABC):
|
||||
Returns:
|
||||
A [fuzzy matcher][textual.fuzzy.Matcher] object for matching against candidate hits.
|
||||
"""
|
||||
match_style = self.match_style
|
||||
# match_style = Style(bold=True, underline=True)
|
||||
|
||||
return Matcher(
|
||||
user_input, match_style=self.match_style, case_sensitive=case_sensitive
|
||||
user_input,
|
||||
match_style=match_style,
|
||||
case_sensitive=case_sensitive,
|
||||
)
|
||||
|
||||
def _post_init(self) -> None:
|
||||
@@ -806,9 +814,7 @@ class CommandPalette(SystemModalScreen[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 = [
|
||||
@@ -1108,9 +1114,10 @@ class CommandPalette(SystemModalScreen[None]):
|
||||
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)
|
||||
|
||||
@@ -948,7 +948,7 @@ class Content(Visual):
|
||||
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.
|
||||
|
||||
@@ -971,11 +971,13 @@ class Content(Visual):
|
||||
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:
|
||||
|
||||
@@ -220,7 +220,7 @@ class Stylesheet:
|
||||
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:
|
||||
@@ -229,6 +229,8 @@ class Stylesheet:
|
||||
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)
|
||||
|
||||
@@ -12,8 +12,9 @@ from re import IGNORECASE, escape, finditer, search
|
||||
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):
|
||||
@@ -203,7 +204,7 @@ class Matcher:
|
||||
"""
|
||||
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:
|
||||
@@ -212,11 +213,11 @@ class Matcher:
|
||||
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
|
||||
|
||||
@@ -177,10 +177,14 @@ class 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,
|
||||
@@ -368,6 +372,7 @@ class Style:
|
||||
bold=self.bold,
|
||||
dim=self.dim,
|
||||
italic=self.italic,
|
||||
underline=self.underline,
|
||||
reverse=self.reverse,
|
||||
strike=self.strike,
|
||||
link=self.link,
|
||||
|
||||
@@ -1044,17 +1044,20 @@ class Widget(DOMNode):
|
||||
|
||||
return partial_style if partial else style
|
||||
|
||||
def get_visual_style(self, *component_classes: str) -> VisualStyle:
|
||||
def get_visual_style(
|
||||
self, *component_classes: str, partial: bool = False
|
||||
) -> VisualStyle:
|
||||
"""Get the visual style for the widget, including any component styles.
|
||||
|
||||
Args:
|
||||
component_classes: Optional component styles.
|
||||
partial: Return a partial style (not combined with parent).
|
||||
|
||||
Returns:
|
||||
A Visual style instance.
|
||||
|
||||
"""
|
||||
cache_key = (self._pseudo_classes_cache_key, component_classes)
|
||||
cache_key = (self._pseudo_classes_cache_key, component_classes, partial)
|
||||
if (visual_style := self._visual_style_cache.get(cache_key, None)) is None:
|
||||
background = Color(0, 0, 0, 0)
|
||||
color = Color(255, 255, 255, 0)
|
||||
@@ -1064,6 +1067,9 @@ class Widget(DOMNode):
|
||||
|
||||
def iter_styles() -> Iterable[StylesBase]:
|
||||
"""Iterate over the styles from the DOM and additional components styles."""
|
||||
if partial:
|
||||
node = self
|
||||
else:
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
yield node.styles
|
||||
for name in component_classes:
|
||||
@@ -4221,7 +4227,7 @@ class Widget(DOMNode):
|
||||
"""
|
||||
self.app.capture_mouse(None)
|
||||
|
||||
def select_all(self) -> None:
|
||||
def text_select_all(self) -> None:
|
||||
"""Select the entire widget."""
|
||||
self.screen._select_all_in_widget(self)
|
||||
|
||||
@@ -4286,9 +4292,9 @@ class Widget(DOMNode):
|
||||
async def _on_click(self, event: events.Click) -> None:
|
||||
if event.widget is self:
|
||||
if event.chain == 2:
|
||||
self.select_all()
|
||||
self.text_select_all()
|
||||
elif event.chain == 3 and self.parent is not None:
|
||||
self.select_container.select_all()
|
||||
self.select_container.text_select_all()
|
||||
|
||||
await self.broker_event("click", event)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from textual._loop import loop_last
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.cache import LRUCache
|
||||
from textual.css.styles import RulesMap
|
||||
from textual.geometry import Region, Size
|
||||
from textual.geometry import Region, Size, clamp
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.scroll_view import ScrollView
|
||||
@@ -57,7 +57,7 @@ class Option:
|
||||
self._prompt = prompt
|
||||
self._visual: Visual | None = None
|
||||
self._id = id
|
||||
self._disabled = disabled
|
||||
self.disabled = disabled
|
||||
self._divider = False
|
||||
|
||||
@property
|
||||
@@ -70,9 +70,15 @@ class Option:
|
||||
"""Optional ID for the option."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def disabled(self) -> bool:
|
||||
return self._disabled
|
||||
def _set_prompt(self, prompt: VisualType) -> None:
|
||||
"""Update the prompt.
|
||||
|
||||
Args:
|
||||
prompt: New prompt.
|
||||
|
||||
"""
|
||||
self._prompt = prompt
|
||||
self._visual = None
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return id(self)
|
||||
@@ -180,7 +186,7 @@ class OptionList(ScrollView, can_focus=True):
|
||||
highlighted: reactive[int | None] = reactive(None)
|
||||
"""The index of the currently-highlighted option, or `None` if no option is highlighted."""
|
||||
|
||||
hover_option_index: reactive[int | None] = reactive(None)
|
||||
_mouse_hovering_over: reactive[int | None] = reactive(None)
|
||||
"""The index of the option under the mouse or `None`."""
|
||||
|
||||
class OptionMessage(Message):
|
||||
@@ -247,14 +253,12 @@ class OptionList(ScrollView, can_focus=True):
|
||||
tooltip: VisualType | None = None,
|
||||
):
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
self._option_id = 0
|
||||
self._wrap = wrap
|
||||
self._markup = markup
|
||||
self._options: list[Option] = []
|
||||
self._id_to_option: dict[str, Option] = {}
|
||||
self._option_to_index: dict[Option, int] = {}
|
||||
|
||||
self._visuals: dict[int, Visual] = {}
|
||||
self._option_render_cache: LRUCache[tuple[Option, Style], list[Strip]]
|
||||
self._option_render_cache = LRUCache(maxsize=1024)
|
||||
|
||||
@@ -264,6 +268,9 @@ class OptionList(ScrollView, can_focus=True):
|
||||
self.tooltip = tooltip
|
||||
|
||||
self.add_options(content)
|
||||
if self._options:
|
||||
# TODO: Inherited from previous version. Do we always want this?
|
||||
self.action_first()
|
||||
|
||||
@property
|
||||
def options(self) -> Sequence[Option]:
|
||||
@@ -285,8 +292,11 @@ class OptionList(ScrollView, can_focus=True):
|
||||
Returns:
|
||||
The `OptionList` instance.
|
||||
"""
|
||||
self._options.clear()
|
||||
self._line_cache.clear()
|
||||
self._option_render_cache.clear()
|
||||
self._id_to_option.clear()
|
||||
self._option_to_index.clear()
|
||||
self.highlighted = None
|
||||
self.refresh()
|
||||
return self
|
||||
@@ -298,6 +308,16 @@ class OptionList(ScrollView, can_focus=True):
|
||||
new_options: Content of new options.
|
||||
"""
|
||||
|
||||
option_ids = [
|
||||
option._id
|
||||
for option in new_options
|
||||
if isinstance(option, Option) and option._id is not None
|
||||
]
|
||||
if len(option_ids) != len(set(option_ids)):
|
||||
raise DuplicateID(
|
||||
"New options contain duplicated IDs; Ensure that the IDs are unique."
|
||||
)
|
||||
|
||||
options = self._options
|
||||
add_option = self._options.append
|
||||
for prompt in new_options:
|
||||
@@ -309,17 +329,15 @@ class OptionList(ScrollView, can_focus=True):
|
||||
continue
|
||||
else:
|
||||
option = Option(prompt)
|
||||
hash(option)
|
||||
self._option_to_index[option] = len(options)
|
||||
add_option(option)
|
||||
if option._id is not None:
|
||||
if option._id in self._id_to_option:
|
||||
raise DuplicateID("Unable to add {option!r} due to duplicate ID")
|
||||
raise DuplicateID(f"Unable to add {option!r} due to duplicate ID")
|
||||
self._id_to_option[option._id] = option
|
||||
|
||||
add_option(option)
|
||||
return self
|
||||
|
||||
def add_option(self, option: Option | VisualType | None) -> Self:
|
||||
def add_option(self, option: Option | VisualType | None = None) -> Self:
|
||||
"""Add a new option to the end of the option list.
|
||||
|
||||
Args:
|
||||
@@ -397,7 +415,7 @@ class OptionList(ScrollView, can_focus=True):
|
||||
Returns:
|
||||
The `OptionList` instance.
|
||||
"""
|
||||
self._options[index]._disabled = disabled
|
||||
self._options[index].disabled = disabled
|
||||
if index == self.highlighted:
|
||||
self.highlighted = _widget_navigation.find_next_enabled(
|
||||
self._options, anchor=index, direction=1
|
||||
@@ -480,6 +498,8 @@ class OptionList(ScrollView, can_focus=True):
|
||||
"""
|
||||
|
||||
index = self._option_to_index[option]
|
||||
self._mouse_hovering_over = None
|
||||
self._pre_remove_option(option, index)
|
||||
for option in self.options[index + 1 :]:
|
||||
current_index = self._option_to_index[option]
|
||||
self._option_to_index[option] = current_index - 1
|
||||
@@ -489,9 +509,19 @@ class OptionList(ScrollView, can_focus=True):
|
||||
if option._id is not None:
|
||||
del self._id_to_option[option._id]
|
||||
del self._option_to_index[option]
|
||||
self.highlighted = self.highlighted
|
||||
self.refresh()
|
||||
return self
|
||||
|
||||
def _pre_remove_option(self, option: Option, index: int) -> None:
|
||||
"""Hook called prior to removing an option.
|
||||
|
||||
Args:
|
||||
option: Option being removed.
|
||||
index: Index of option being removed.
|
||||
|
||||
"""
|
||||
|
||||
def remove_option(self, option_id: str) -> Self:
|
||||
"""Remove the option with the given ID.
|
||||
|
||||
@@ -519,9 +549,59 @@ class OptionList(ScrollView, can_focus=True):
|
||||
Raises:
|
||||
OptionDoesNotExist: If there is no option with the given index.
|
||||
"""
|
||||
try:
|
||||
option = self._options[index]
|
||||
except IndexError:
|
||||
raise OptionDoesNotExist(
|
||||
f"Unable to remove; there is no option at index {index}"
|
||||
) from None
|
||||
return self._remove_option(option)
|
||||
|
||||
def _replace_option_prompt(self, index: int, prompt: VisualType) -> None:
|
||||
"""Replace the prompt of an option in the list.
|
||||
|
||||
Args:
|
||||
index: The index of the option to replace the prompt of.
|
||||
prompt: The new prompt for the option.
|
||||
|
||||
Raises:
|
||||
OptionDoesNotExist: If there is no option with the given index.
|
||||
"""
|
||||
self.get_option_at_index(index)._set_prompt(prompt)
|
||||
self._clear_caches()
|
||||
|
||||
def replace_option_prompt(self, option_id: str, prompt: VisualType) -> Self:
|
||||
"""Replace the prompt of the option with the given ID.
|
||||
|
||||
Args:
|
||||
option_id: The ID of the option to replace the prompt of.
|
||||
prompt: The new prompt for the option.
|
||||
|
||||
Returns:
|
||||
The `OptionList` instance.
|
||||
|
||||
Raises:
|
||||
OptionDoesNotExist: If no option has the given ID.
|
||||
"""
|
||||
self._replace_option_prompt(self.get_option_index(option_id), prompt)
|
||||
return self
|
||||
|
||||
def replace_option_prompt_at_index(self, index: int, prompt: VisualType) -> Self:
|
||||
"""Replace the prompt of the option at the given index.
|
||||
|
||||
Args:
|
||||
index: The index of the option to replace the prompt of.
|
||||
prompt: The new prompt for the option.
|
||||
|
||||
Returns:
|
||||
The `OptionList` instance.
|
||||
|
||||
Raises:
|
||||
OptionDoesNotExist: If there is no option with the given index.
|
||||
"""
|
||||
self._replace_option_prompt(index, prompt)
|
||||
return self
|
||||
|
||||
@property
|
||||
def _lines(self) -> Sequence[tuple[int, int]]:
|
||||
"""A sequence of pairs of ints for each line, used internally.
|
||||
@@ -549,6 +629,7 @@ class OptionList(ScrollView, can_focus=True):
|
||||
def _clear_caches(self) -> None:
|
||||
self._option_render_cache.clear()
|
||||
self._line_cache.clear()
|
||||
self.refresh()
|
||||
|
||||
def notify_style_update(self) -> None:
|
||||
self._clear_caches()
|
||||
@@ -557,6 +638,9 @@ class OptionList(ScrollView, can_focus=True):
|
||||
self._clear_caches()
|
||||
self.refresh()
|
||||
|
||||
def _on_mount(self) -> None:
|
||||
self._update_lines()
|
||||
|
||||
def on_show(self) -> None:
|
||||
self.scroll_to_highlight()
|
||||
|
||||
@@ -567,11 +651,7 @@ class OptionList(ScrollView, can_focus=True):
|
||||
event: The click event.
|
||||
"""
|
||||
clicked_option: int | None = event.style.meta.get("option")
|
||||
if (
|
||||
clicked_option is not None
|
||||
and clicked_option >= 0
|
||||
and not self._options[clicked_option].disabled
|
||||
):
|
||||
if clicked_option is not None and not self._options[clicked_option].disabled:
|
||||
self.highlighted = clicked_option
|
||||
self.action_select()
|
||||
|
||||
@@ -589,7 +669,7 @@ class OptionList(ScrollView, can_focus=True):
|
||||
Args:
|
||||
event: The mouse movement event.
|
||||
"""
|
||||
self.hover_option_index = event.style.meta.get("option")
|
||||
self._mouse_hovering_over = event.style.meta.get("option")
|
||||
|
||||
def _on_leave(self, _: events.Leave) -> None:
|
||||
"""React to the mouse leaving the widget."""
|
||||
@@ -633,7 +713,7 @@ class OptionList(ScrollView, can_focus=True):
|
||||
A list of strips.
|
||||
"""
|
||||
padding = self.get_component_styles("option-list--option").padding
|
||||
width = self.content_region.width - self._get_left_gutter_width()
|
||||
width = self.scrollable_content_region.width - self._get_left_gutter_width()
|
||||
cache_key = (option, style)
|
||||
if (strips := self._option_render_cache.get(cache_key)) is None:
|
||||
visual = self._get_visual(option)
|
||||
@@ -657,14 +737,16 @@ class OptionList(ScrollView, can_focus=True):
|
||||
def _update_lines(self) -> None:
|
||||
line_cache = self._line_cache
|
||||
lines = line_cache.lines
|
||||
last_index = lines[-1][0] if lines else 0
|
||||
if not self.options:
|
||||
return
|
||||
|
||||
next_index = lines[-1][0] + 1 if lines else 0
|
||||
get_visual = self._get_visual
|
||||
width = self.scrollable_content_region.width
|
||||
|
||||
if last_index < len(self.options) - 1:
|
||||
if next_index < len(self.options):
|
||||
styles = self.get_component_styles("option-list--option")
|
||||
|
||||
for index, option in enumerate(self.options[last_index:], last_index):
|
||||
for index, option in enumerate(self.options[next_index:], next_index):
|
||||
line_cache.index_to_line[index] = len(line_cache.lines)
|
||||
line_count = (
|
||||
get_visual(option).get_height(styles, width) + option._divider
|
||||
@@ -716,6 +798,10 @@ class OptionList(ScrollView, can_focus=True):
|
||||
strips = self._get_option_render(option, style)
|
||||
return strips[line_offset]
|
||||
|
||||
def render_lines(self, crop: Region) -> list[Strip]:
|
||||
self._update_lines()
|
||||
return super().render_lines(crop)
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
line_number = self.scroll_offset.y + y
|
||||
try:
|
||||
@@ -723,25 +809,20 @@ class OptionList(ScrollView, can_focus=True):
|
||||
except IndexError:
|
||||
return Strip.blank(self.scrollable_content_region.width)
|
||||
option = self.options[option_index]
|
||||
mouse_over = self.hover_option_index == option_index
|
||||
component_classes: tuple[str, ...] = ("option-list--option",)
|
||||
mouse_over = self._mouse_hovering_over == option_index
|
||||
component_class = ""
|
||||
if option.disabled:
|
||||
component_classes = (
|
||||
"option-list--option",
|
||||
"option-list--option-disabled",
|
||||
)
|
||||
component_class = "option-list--option-disabled"
|
||||
elif self.highlighted == option_index:
|
||||
component_classes = (
|
||||
"option-list--option",
|
||||
"option-list--option-highlighted",
|
||||
)
|
||||
component_class = "option-list--option-highlighted"
|
||||
elif mouse_over:
|
||||
component_classes = (
|
||||
"option-list--option",
|
||||
"option-list--option-hover",
|
||||
)
|
||||
component_class = "option-list--option-hover"
|
||||
|
||||
if component_class:
|
||||
style = self.get_visual_style("option-list--option", component_class)
|
||||
else:
|
||||
style = self.get_visual_style("option-list--option")
|
||||
|
||||
style = self.get_visual_style(*component_classes)
|
||||
strips = self._get_option_render(option, style)
|
||||
strip = strips[line_offset]
|
||||
return strip
|
||||
@@ -760,23 +841,28 @@ class OptionList(ScrollView, can_focus=True):
|
||||
"""React to the highlighted option having changed."""
|
||||
if highlighted is None:
|
||||
return
|
||||
|
||||
if not self._options[highlighted].disabled:
|
||||
self.scroll_to_highlight()
|
||||
|
||||
option: Option
|
||||
if highlighted is None:
|
||||
option = None
|
||||
else:
|
||||
option = self.options[highlighted]
|
||||
self.post_message(self.OptionHighlighted(self, option, highlighted))
|
||||
self.post_message(
|
||||
self.OptionHighlighted(self, self.options[highlighted], highlighted)
|
||||
)
|
||||
|
||||
def scroll_to_highlight(self, top: bool = False) -> None:
|
||||
"""Scroll to the highlighted option.
|
||||
|
||||
Args:
|
||||
top: Ensure highlighted option is at the top of the widget.
|
||||
"""
|
||||
highlighted = self.highlighted
|
||||
if highlighted is None or not self.is_mounted:
|
||||
return
|
||||
|
||||
self._update_lines()
|
||||
|
||||
try:
|
||||
y = self._index_to_line[highlighted]
|
||||
except KeyError:
|
||||
return
|
||||
option = self.options[highlighted]
|
||||
height = self._heights[highlighted] - option._divider
|
||||
|
||||
@@ -820,32 +906,35 @@ class OptionList(ScrollView, can_focus=True):
|
||||
Args:
|
||||
direction: `-1` to move up a page, `1` to move down a page.
|
||||
"""
|
||||
if not self._options:
|
||||
return
|
||||
|
||||
height = self.content_region.height
|
||||
|
||||
option_index = self.highlighted or 0
|
||||
|
||||
y = min(
|
||||
self._index_to_line[option_index] + direction * height,
|
||||
height = self.scrollable_content_region.height
|
||||
y = clamp(
|
||||
self._index_to_line[self.highlighted or 0] + direction * height,
|
||||
0,
|
||||
len(self._lines) - 1,
|
||||
)
|
||||
option_index = self._lines[y][0]
|
||||
|
||||
target_option = _widget_navigation.find_next_enabled_no_wrap(
|
||||
self.highlighted = _widget_navigation.find_next_enabled_no_wrap(
|
||||
candidates=self._options,
|
||||
anchor=option_index,
|
||||
direction=direction,
|
||||
with_anchor=True,
|
||||
)
|
||||
if target_option is not None:
|
||||
self.highlighted = target_option
|
||||
|
||||
def action_page_up(self):
|
||||
"""Move the highlight up one page."""
|
||||
if self.highlighted is None:
|
||||
self.action_first()
|
||||
else:
|
||||
self._move_page(-1)
|
||||
|
||||
def action_page_down(self):
|
||||
"""Move the highlight down one page."""
|
||||
if self.highlighted is None:
|
||||
self.action_last()
|
||||
else:
|
||||
self._move_page(1)
|
||||
|
||||
def action_select(self) -> None:
|
||||
@@ -854,34 +943,9 @@ class OptionList(ScrollView, can_focus=True):
|
||||
If an option is selected then a
|
||||
[OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected] will be posted.
|
||||
"""
|
||||
if self.highlighted is None:
|
||||
return
|
||||
highlighted = self.highlighted
|
||||
if highlighted is None:
|
||||
return
|
||||
option = self._options[highlighted]
|
||||
if highlighted is not None and not option.disabled:
|
||||
self.post_message(self.OptionSelected(self, option, highlighted))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from textual.app import App, ComposeResult
|
||||
|
||||
TEXT = """I must not fear.
|
||||
Fear is the [u]mind-killer[/u].
|
||||
Fear is the little-death that brings total obliteration.
|
||||
I will face my fear.
|
||||
I will permit it to pass over me and through me.
|
||||
And when it has gone past, I will turn the inner eye to see its path.
|
||||
Where the fear has gone there will be nothing. Only I will remain."""
|
||||
|
||||
class OLApp(App):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield OptionList(
|
||||
*(
|
||||
["Hello", "World!", None, TEXT, None, "Foo", "Bar", "Baz", None]
|
||||
* 100
|
||||
)
|
||||
)
|
||||
|
||||
app = OLApp()
|
||||
app.run()
|
||||
|
||||
@@ -15,7 +15,12 @@ from textual import events
|
||||
from textual.binding import Binding
|
||||
from textual.messages import Message
|
||||
from textual.strip import Strip
|
||||
from textual.widgets._option_list import Option, OptionList, OptionListContent
|
||||
from textual.widgets._option_list import (
|
||||
Option,
|
||||
OptionDoesNotExist,
|
||||
OptionList,
|
||||
OptionListContent,
|
||||
)
|
||||
from textual.widgets._toggle_button import ToggleButton
|
||||
|
||||
SelectionType = TypeVar("SelectionType")
|
||||
@@ -229,6 +234,10 @@ class SelectionList(Generic[SelectionType], OptionList):
|
||||
self._send_messages = False
|
||||
"""Keep track of when we're ready to start sending messages."""
|
||||
options = [self._make_selection(selection) for selection in selections]
|
||||
self._values: dict[SelectionType, int] = {
|
||||
option.value: index for index, option in enumerate(options)
|
||||
}
|
||||
"""Keeps track of which value relates to which option."""
|
||||
super().__init__(
|
||||
*options,
|
||||
name=name,
|
||||
@@ -237,10 +246,6 @@ class SelectionList(Generic[SelectionType], OptionList):
|
||||
disabled=disabled,
|
||||
wrap=False,
|
||||
)
|
||||
self._values: dict[SelectionType, int] = {
|
||||
option.value: index for index, option in enumerate(options)
|
||||
}
|
||||
"""Keeps track of which value relates to which option."""
|
||||
|
||||
@property
|
||||
def selected(self) -> list[SelectionType]:
|
||||
@@ -510,20 +515,25 @@ class SelectionList(Generic[SelectionType], OptionList):
|
||||
A [`Strip`][textual.strip.Strip] that is the line to render.
|
||||
"""
|
||||
|
||||
# First off, get the underlying prompt from OptionList.
|
||||
prompt = super().render_line(y)
|
||||
# TODO: This is rather crufty and hard to fathom. Candidate for a rewrite.
|
||||
|
||||
# If it looks like the prompt itself is actually an empty line...
|
||||
if not prompt:
|
||||
# ...get out with that. We don't need to do any more here.
|
||||
return prompt
|
||||
# First off, get the underlying prompt from OptionList.
|
||||
line = super().render_line(y)
|
||||
|
||||
# # If it looks like the prompt itself is actually an empty line...
|
||||
# if not prompt:
|
||||
# # ...get out with that. We don't need to do any more here.
|
||||
# return prompt
|
||||
|
||||
# We know the prompt we're going to display, what we're going to do
|
||||
# is place a CheckBox-a-like button next to it. So to start with
|
||||
# let's pull out the actual Selection we're looking at right now.
|
||||
_, scroll_y = self.scroll_offset
|
||||
selection_index = scroll_y + y
|
||||
try:
|
||||
selection = self.get_option_at_index(selection_index)
|
||||
except OptionDoesNotExist:
|
||||
return line
|
||||
|
||||
# Figure out which component style is relevant for a checkbox on
|
||||
# this particular line.
|
||||
@@ -533,10 +543,12 @@ class SelectionList(Generic[SelectionType], OptionList):
|
||||
if self.highlighted == selection_index:
|
||||
component_style += "-highlighted"
|
||||
|
||||
# Get the underlying style used for the prompt.
|
||||
underlying_style = next(iter(prompt)).style
|
||||
# # # Get the underlying style used for the prompt.
|
||||
underlying_style = next(iter(line)).style or self.rich_style
|
||||
assert underlying_style is not None
|
||||
|
||||
# underlying_style = self.rich_style
|
||||
|
||||
# Get the style for the button.
|
||||
button_style = self.get_component_rich_style(component_style)
|
||||
|
||||
@@ -565,7 +577,7 @@ class SelectionList(Generic[SelectionType], OptionList):
|
||||
Segment(ToggleButton.BUTTON_INNER, style=button_style),
|
||||
Segment(ToggleButton.BUTTON_RIGHT, style=side_style),
|
||||
Segment(" ", style=underlying_style),
|
||||
*prompt,
|
||||
*line,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -617,24 +629,17 @@ class SelectionList(Generic[SelectionType], OptionList):
|
||||
"""
|
||||
return cast("Selection[SelectionType]", super().get_option(option_id))
|
||||
|
||||
def _remove_option(self, index: int) -> None:
|
||||
"""Remove a selection option from the selection option list.
|
||||
|
||||
Args:
|
||||
index: The index of the selection option to remove.
|
||||
|
||||
Raises:
|
||||
IndexError: If there is no selection option of the given index.
|
||||
"""
|
||||
option = self.get_option_at_index(index)
|
||||
def _pre_remove_option(self, option: Option, index: int) -> None:
|
||||
"""Hook called prior to removing an option."""
|
||||
assert isinstance(option, Selection)
|
||||
self._deselect(option.value)
|
||||
del self._values[option.value]
|
||||
|
||||
# Decrement index of options after the one we just removed.
|
||||
self._values = {
|
||||
option_value: option_index - 1 if option_index > index else option_index
|
||||
for option_value, option_index in self._values.items()
|
||||
}
|
||||
return super()._remove_option(index)
|
||||
|
||||
def add_options(
|
||||
self,
|
||||
|
||||
@@ -6,12 +6,7 @@ import pytest
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import (
|
||||
DuplicateID,
|
||||
Option,
|
||||
OptionDoesNotExist,
|
||||
Separator,
|
||||
)
|
||||
from textual.widgets.option_list import DuplicateID, Option, OptionDoesNotExist
|
||||
|
||||
|
||||
class OptionListApp(App[None]):
|
||||
@@ -21,7 +16,7 @@ class OptionListApp(App[None]):
|
||||
yield OptionList(
|
||||
"0",
|
||||
Option("1"),
|
||||
Separator(),
|
||||
None,
|
||||
Option("2", disabled=True),
|
||||
None,
|
||||
Option("3", id="3"),
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option, Separator
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
|
||||
class OptionListApp(App[None]):
|
||||
@@ -12,7 +12,7 @@ class OptionListApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield OptionList(
|
||||
Option("0"),
|
||||
Separator(),
|
||||
None,
|
||||
Option("1"),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user