diff options
Diffstat (limited to 'src/prompt_toolkit/widgets/base.py')
-rw-r--r-- | src/prompt_toolkit/widgets/base.py | 981 |
1 files changed, 981 insertions, 0 deletions
diff --git a/src/prompt_toolkit/widgets/base.py b/src/prompt_toolkit/widgets/base.py new file mode 100644 index 0000000..f36a545 --- /dev/null +++ b/src/prompt_toolkit/widgets/base.py @@ -0,0 +1,981 @@ +""" +Collection of reusable components for building full screen applications. + +All of these widgets implement the ``__pt_container__`` method, which makes +them usable in any situation where we are expecting a `prompt_toolkit` +container object. + +.. warning:: + + At this point, the API for these widgets is considered unstable, and can + potentially change between minor releases (we try not too, but no + guarantees are made yet). The public API in + `prompt_toolkit.shortcuts.dialogs` on the other hand is considered stable. +""" +from __future__ import annotations + +from functools import partial +from typing import Callable, Generic, Sequence, TypeVar + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.auto_suggest import AutoSuggest, DynamicAutoSuggest +from prompt_toolkit.buffer import Buffer, BufferAcceptHandler +from prompt_toolkit.completion import Completer, DynamicCompleter +from prompt_toolkit.document import Document +from prompt_toolkit.filters import ( + Condition, + FilterOrBool, + has_focus, + is_done, + is_true, + to_filter, +) +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + Template, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import fragment_list_to_text +from prompt_toolkit.history import History +from prompt_toolkit.key_binding.key_bindings import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout.containers import ( + AnyContainer, + ConditionalContainer, + Container, + DynamicContainer, + Float, + FloatContainer, + HSplit, + VSplit, + Window, + WindowAlign, +) +from prompt_toolkit.layout.controls import ( + BufferControl, + FormattedTextControl, + GetLinePrefixCallable, +) +from prompt_toolkit.layout.dimension import AnyDimension, to_dimension +from prompt_toolkit.layout.dimension import Dimension as D +from prompt_toolkit.layout.margins import ( + ConditionalMargin, + NumberedMargin, + ScrollbarMargin, +) +from prompt_toolkit.layout.processors import ( + AppendAutoSuggestion, + BeforeInput, + ConditionalProcessor, + PasswordProcessor, + Processor, +) +from prompt_toolkit.lexers import DynamicLexer, Lexer +from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +from prompt_toolkit.utils import get_cwidth +from prompt_toolkit.validation import DynamicValidator, Validator + +from .toolbars import SearchToolbar + +__all__ = [ + "TextArea", + "Label", + "Button", + "Frame", + "Shadow", + "Box", + "VerticalLine", + "HorizontalLine", + "RadioList", + "CheckboxList", + "Checkbox", # backward compatibility + "ProgressBar", +] + +E = KeyPressEvent + + +class Border: + "Box drawing characters. (Thin)" + + HORIZONTAL = "\u2500" + VERTICAL = "\u2502" + TOP_LEFT = "\u250c" + TOP_RIGHT = "\u2510" + BOTTOM_LEFT = "\u2514" + BOTTOM_RIGHT = "\u2518" + + +class TextArea: + """ + A simple input field. + + This is a higher level abstraction on top of several other classes with + sane defaults. + + This widget does have the most common options, but it does not intend to + cover every single use case. For more configurations options, you can + always build a text area manually, using a + :class:`~prompt_toolkit.buffer.Buffer`, + :class:`~prompt_toolkit.layout.BufferControl` and + :class:`~prompt_toolkit.layout.Window`. + + Buffer attributes: + + :param text: The initial text. + :param multiline: If True, allow multiline input. + :param completer: :class:`~prompt_toolkit.completion.Completer` instance + for auto completion. + :param complete_while_typing: Boolean. + :param accept_handler: Called when `Enter` is pressed (This should be a + callable that takes a buffer as input). + :param history: :class:`~prompt_toolkit.history.History` instance. + :param auto_suggest: :class:`~prompt_toolkit.auto_suggest.AutoSuggest` + instance for input suggestions. + + BufferControl attributes: + + :param password: When `True`, display using asterisks. + :param focusable: When `True`, allow this widget to receive the focus. + :param focus_on_click: When `True`, focus after mouse click. + :param input_processors: `None` or a list of + :class:`~prompt_toolkit.layout.Processor` objects. + :param validator: `None` or a :class:`~prompt_toolkit.validation.Validator` + object. + + Window attributes: + + :param lexer: :class:`~prompt_toolkit.lexers.Lexer` instance for syntax + highlighting. + :param wrap_lines: When `True`, don't scroll horizontally, but wrap lines. + :param width: Window width. (:class:`~prompt_toolkit.layout.Dimension` object.) + :param height: Window height. (:class:`~prompt_toolkit.layout.Dimension` object.) + :param scrollbar: When `True`, display a scroll bar. + :param style: A style string. + :param dont_extend_width: When `True`, don't take up more width then the + preferred width reported by the control. + :param dont_extend_height: When `True`, don't take up more width then the + preferred height reported by the control. + :param get_line_prefix: None or a callable that returns formatted text to + be inserted before a line. It takes a line number (int) and a + wrap_count and returns formatted text. This can be used for + implementation of line continuations, things like Vim "breakindent" and + so on. + + Other attributes: + + :param search_field: An optional `SearchToolbar` object. + """ + + def __init__( + self, + text: str = "", + multiline: FilterOrBool = True, + password: FilterOrBool = False, + lexer: Lexer | None = None, + auto_suggest: AutoSuggest | None = None, + completer: Completer | None = None, + complete_while_typing: FilterOrBool = True, + validator: Validator | None = None, + accept_handler: BufferAcceptHandler | None = None, + history: History | None = None, + focusable: FilterOrBool = True, + focus_on_click: FilterOrBool = False, + wrap_lines: FilterOrBool = True, + read_only: FilterOrBool = False, + width: AnyDimension = None, + height: AnyDimension = None, + dont_extend_height: FilterOrBool = False, + dont_extend_width: FilterOrBool = False, + line_numbers: bool = False, + get_line_prefix: GetLinePrefixCallable | None = None, + scrollbar: bool = False, + style: str = "", + search_field: SearchToolbar | None = None, + preview_search: FilterOrBool = True, + prompt: AnyFormattedText = "", + input_processors: list[Processor] | None = None, + name: str = "", + ) -> None: + if search_field is None: + search_control = None + elif isinstance(search_field, SearchToolbar): + search_control = search_field.control + + if input_processors is None: + input_processors = [] + + # Writeable attributes. + self.completer = completer + self.complete_while_typing = complete_while_typing + self.lexer = lexer + self.auto_suggest = auto_suggest + self.read_only = read_only + self.wrap_lines = wrap_lines + self.validator = validator + + self.buffer = Buffer( + document=Document(text, 0), + multiline=multiline, + read_only=Condition(lambda: is_true(self.read_only)), + completer=DynamicCompleter(lambda: self.completer), + complete_while_typing=Condition( + lambda: is_true(self.complete_while_typing) + ), + validator=DynamicValidator(lambda: self.validator), + auto_suggest=DynamicAutoSuggest(lambda: self.auto_suggest), + accept_handler=accept_handler, + history=history, + name=name, + ) + + self.control = BufferControl( + buffer=self.buffer, + lexer=DynamicLexer(lambda: self.lexer), + input_processors=[ + ConditionalProcessor( + AppendAutoSuggestion(), has_focus(self.buffer) & ~is_done + ), + ConditionalProcessor( + processor=PasswordProcessor(), filter=to_filter(password) + ), + BeforeInput(prompt, style="class:text-area.prompt"), + ] + + input_processors, + search_buffer_control=search_control, + preview_search=preview_search, + focusable=focusable, + focus_on_click=focus_on_click, + ) + + if multiline: + if scrollbar: + right_margins = [ScrollbarMargin(display_arrows=True)] + else: + right_margins = [] + if line_numbers: + left_margins = [NumberedMargin()] + else: + left_margins = [] + else: + height = D.exact(1) + left_margins = [] + right_margins = [] + + style = "class:text-area " + style + + # If no height was given, guarantee height of at least 1. + if height is None: + height = D(min=1) + + self.window = Window( + height=height, + width=width, + dont_extend_height=dont_extend_height, + dont_extend_width=dont_extend_width, + content=self.control, + style=style, + wrap_lines=Condition(lambda: is_true(self.wrap_lines)), + left_margins=left_margins, + right_margins=right_margins, + get_line_prefix=get_line_prefix, + ) + + @property + def text(self) -> str: + """ + The `Buffer` text. + """ + return self.buffer.text + + @text.setter + def text(self, value: str) -> None: + self.document = Document(value, 0) + + @property + def document(self) -> Document: + """ + The `Buffer` document (text + cursor position). + """ + return self.buffer.document + + @document.setter + def document(self, value: Document) -> None: + self.buffer.set_document(value, bypass_readonly=True) + + @property + def accept_handler(self) -> BufferAcceptHandler | None: + """ + The accept handler. Called when the user accepts the input. + """ + return self.buffer.accept_handler + + @accept_handler.setter + def accept_handler(self, value: BufferAcceptHandler) -> None: + self.buffer.accept_handler = value + + def __pt_container__(self) -> Container: + return self.window + + +class Label: + """ + Widget that displays the given text. It is not editable or focusable. + + :param text: Text to display. Can be multiline. All value types accepted by + :class:`prompt_toolkit.layout.FormattedTextControl` are allowed, + including a callable. + :param style: A style string. + :param width: When given, use this width, rather than calculating it from + the text size. + :param dont_extend_width: When `True`, don't take up more width than + preferred, i.e. the length of the longest line of + the text, or value of `width` parameter, if + given. `True` by default + :param dont_extend_height: When `True`, don't take up more width than the + preferred height, i.e. the number of lines of + the text. `False` by default. + """ + + def __init__( + self, + text: AnyFormattedText, + style: str = "", + width: AnyDimension = None, + dont_extend_height: bool = True, + dont_extend_width: bool = False, + align: WindowAlign | Callable[[], WindowAlign] = WindowAlign.LEFT, + # There is no cursor navigation in a label, so it makes sense to always + # wrap lines by default. + wrap_lines: FilterOrBool = True, + ) -> None: + self.text = text + + def get_width() -> AnyDimension: + if width is None: + text_fragments = to_formatted_text(self.text) + text = fragment_list_to_text(text_fragments) + if text: + longest_line = max(get_cwidth(line) for line in text.splitlines()) + else: + return D(preferred=0) + return D(preferred=longest_line) + else: + return width + + self.formatted_text_control = FormattedTextControl(text=lambda: self.text) + + self.window = Window( + content=self.formatted_text_control, + width=get_width, + height=D(min=1), + style="class:label " + style, + dont_extend_height=dont_extend_height, + dont_extend_width=dont_extend_width, + align=align, + wrap_lines=wrap_lines, + ) + + def __pt_container__(self) -> Container: + return self.window + + +class Button: + """ + Clickable button. + + :param text: The caption for the button. + :param handler: `None` or callable. Called when the button is clicked. No + parameters are passed to this callable. Use for instance Python's + `functools.partial` to pass parameters to this callable if needed. + :param width: Width of the button. + """ + + def __init__( + self, + text: str, + handler: Callable[[], None] | None = None, + width: int = 12, + left_symbol: str = "<", + right_symbol: str = ">", + ) -> None: + self.text = text + self.left_symbol = left_symbol + self.right_symbol = right_symbol + self.handler = handler + self.width = width + self.control = FormattedTextControl( + self._get_text_fragments, + key_bindings=self._get_key_bindings(), + focusable=True, + ) + + def get_style() -> str: + if get_app().layout.has_focus(self): + return "class:button.focused" + else: + return "class:button" + + # Note: `dont_extend_width` is False, because we want to allow buttons + # to take more space if the parent container provides more space. + # Otherwise, we will also truncate the text. + # Probably we need a better way here to adjust to width of the + # button to the text. + + self.window = Window( + self.control, + align=WindowAlign.CENTER, + height=1, + width=width, + style=get_style, + dont_extend_width=False, + dont_extend_height=True, + ) + + def _get_text_fragments(self) -> StyleAndTextTuples: + width = self.width - ( + get_cwidth(self.left_symbol) + get_cwidth(self.right_symbol) + ) + text = (f"{{:^{width}}}").format(self.text) + + def handler(mouse_event: MouseEvent) -> None: + if ( + self.handler is not None + and mouse_event.event_type == MouseEventType.MOUSE_UP + ): + self.handler() + + return [ + ("class:button.arrow", self.left_symbol, handler), + ("[SetCursorPosition]", ""), + ("class:button.text", text, handler), + ("class:button.arrow", self.right_symbol, handler), + ] + + def _get_key_bindings(self) -> KeyBindings: + "Key bindings for the Button." + kb = KeyBindings() + + @kb.add(" ") + @kb.add("enter") + def _(event: E) -> None: + if self.handler is not None: + self.handler() + + return kb + + def __pt_container__(self) -> Container: + return self.window + + +class Frame: + """ + Draw a border around any container, optionally with a title text. + + Changing the title and body of the frame is possible at runtime by + assigning to the `body` and `title` attributes of this class. + + :param body: Another container object. + :param title: Text to be displayed in the top of the frame (can be formatted text). + :param style: Style string to be applied to this widget. + """ + + def __init__( + self, + body: AnyContainer, + title: AnyFormattedText = "", + style: str = "", + width: AnyDimension = None, + height: AnyDimension = None, + key_bindings: KeyBindings | None = None, + modal: bool = False, + ) -> None: + self.title = title + self.body = body + + fill = partial(Window, style="class:frame.border") + style = "class:frame " + style + + top_row_with_title = VSplit( + [ + fill(width=1, height=1, char=Border.TOP_LEFT), + fill(char=Border.HORIZONTAL), + fill(width=1, height=1, char="|"), + # Notice: we use `Template` here, because `self.title` can be an + # `HTML` object for instance. + Label( + lambda: Template(" {} ").format(self.title), + style="class:frame.label", + dont_extend_width=True, + ), + fill(width=1, height=1, char="|"), + fill(char=Border.HORIZONTAL), + fill(width=1, height=1, char=Border.TOP_RIGHT), + ], + height=1, + ) + + top_row_without_title = VSplit( + [ + fill(width=1, height=1, char=Border.TOP_LEFT), + fill(char=Border.HORIZONTAL), + fill(width=1, height=1, char=Border.TOP_RIGHT), + ], + height=1, + ) + + @Condition + def has_title() -> bool: + return bool(self.title) + + self.container = HSplit( + [ + ConditionalContainer(content=top_row_with_title, filter=has_title), + ConditionalContainer(content=top_row_without_title, filter=~has_title), + VSplit( + [ + fill(width=1, char=Border.VERTICAL), + DynamicContainer(lambda: self.body), + fill(width=1, char=Border.VERTICAL), + # Padding is required to make sure that if the content is + # too small, the right frame border is still aligned. + ], + padding=0, + ), + VSplit( + [ + fill(width=1, height=1, char=Border.BOTTOM_LEFT), + fill(char=Border.HORIZONTAL), + fill(width=1, height=1, char=Border.BOTTOM_RIGHT), + ], + # specifying height here will increase the rendering speed. + height=1, + ), + ], + width=width, + height=height, + style=style, + key_bindings=key_bindings, + modal=modal, + ) + + def __pt_container__(self) -> Container: + return self.container + + +class Shadow: + """ + Draw a shadow underneath/behind this container. + (This applies `class:shadow` the the cells under the shadow. The Style + should define the colors for the shadow.) + + :param body: Another container object. + """ + + def __init__(self, body: AnyContainer) -> None: + self.container = FloatContainer( + content=body, + floats=[ + Float( + bottom=-1, + height=1, + left=1, + right=-1, + transparent=True, + content=Window(style="class:shadow"), + ), + Float( + bottom=-1, + top=1, + width=1, + right=-1, + transparent=True, + content=Window(style="class:shadow"), + ), + ], + ) + + def __pt_container__(self) -> Container: + return self.container + + +class Box: + """ + Add padding around a container. + + This also makes sure that the parent can provide more space than required by + the child. This is very useful when wrapping a small element with a fixed + size into a ``VSplit`` or ``HSplit`` object. The ``HSplit`` and ``VSplit`` + try to make sure to adapt respectively the width and height, possibly + shrinking other elements. Wrapping something in a ``Box`` makes it flexible. + + :param body: Another container object. + :param padding: The margin to be used around the body. This can be + overridden by `padding_left`, padding_right`, `padding_top` and + `padding_bottom`. + :param style: A style string. + :param char: Character to be used for filling the space around the body. + (This is supposed to be a character with a terminal width of 1.) + """ + + def __init__( + self, + body: AnyContainer, + padding: AnyDimension = None, + padding_left: AnyDimension = None, + padding_right: AnyDimension = None, + padding_top: AnyDimension = None, + padding_bottom: AnyDimension = None, + width: AnyDimension = None, + height: AnyDimension = None, + style: str = "", + char: None | str | Callable[[], str] = None, + modal: bool = False, + key_bindings: KeyBindings | None = None, + ) -> None: + if padding is None: + padding = D(preferred=0) + + def get(value: AnyDimension) -> D: + if value is None: + value = padding + return to_dimension(value) + + self.padding_left = get(padding_left) + self.padding_right = get(padding_right) + self.padding_top = get(padding_top) + self.padding_bottom = get(padding_bottom) + self.body = body + + self.container = HSplit( + [ + Window(height=self.padding_top, char=char), + VSplit( + [ + Window(width=self.padding_left, char=char), + body, + Window(width=self.padding_right, char=char), + ] + ), + Window(height=self.padding_bottom, char=char), + ], + width=width, + height=height, + style=style, + modal=modal, + key_bindings=None, + ) + + def __pt_container__(self) -> Container: + return self.container + + +_T = TypeVar("_T") + + +class _DialogList(Generic[_T]): + """ + Common code for `RadioList` and `CheckboxList`. + """ + + open_character: str = "" + close_character: str = "" + container_style: str = "" + default_style: str = "" + selected_style: str = "" + checked_style: str = "" + multiple_selection: bool = False + show_scrollbar: bool = True + + def __init__( + self, + values: Sequence[tuple[_T, AnyFormattedText]], + default_values: Sequence[_T] | None = None, + ) -> None: + assert len(values) > 0 + default_values = default_values or [] + + self.values = values + # current_values will be used in multiple_selection, + # current_value will be used otherwise. + keys: list[_T] = [value for (value, _) in values] + self.current_values: list[_T] = [ + value for value in default_values if value in keys + ] + self.current_value: _T = ( + default_values[0] + if len(default_values) and default_values[0] in keys + else values[0][0] + ) + + # Cursor index: take first selected item or first item otherwise. + if len(self.current_values) > 0: + self._selected_index = keys.index(self.current_values[0]) + else: + self._selected_index = 0 + + # Key bindings. + kb = KeyBindings() + + @kb.add("up") + def _up(event: E) -> None: + self._selected_index = max(0, self._selected_index - 1) + + @kb.add("down") + def _down(event: E) -> None: + self._selected_index = min(len(self.values) - 1, self._selected_index + 1) + + @kb.add("pageup") + def _pageup(event: E) -> None: + w = event.app.layout.current_window + if w.render_info: + self._selected_index = max( + 0, self._selected_index - len(w.render_info.displayed_lines) + ) + + @kb.add("pagedown") + def _pagedown(event: E) -> None: + w = event.app.layout.current_window + if w.render_info: + self._selected_index = min( + len(self.values) - 1, + self._selected_index + len(w.render_info.displayed_lines), + ) + + @kb.add("enter") + @kb.add(" ") + def _click(event: E) -> None: + self._handle_enter() + + @kb.add(Keys.Any) + def _find(event: E) -> None: + # We first check values after the selected value, then all values. + values = list(self.values) + for value in values[self._selected_index + 1 :] + values: + text = fragment_list_to_text(to_formatted_text(value[1])).lower() + + if text.startswith(event.data.lower()): + self._selected_index = self.values.index(value) + return + + # Control and window. + self.control = FormattedTextControl( + self._get_text_fragments, key_bindings=kb, focusable=True + ) + + self.window = Window( + content=self.control, + style=self.container_style, + right_margins=[ + ConditionalMargin( + margin=ScrollbarMargin(display_arrows=True), + filter=Condition(lambda: self.show_scrollbar), + ), + ], + dont_extend_height=True, + ) + + def _handle_enter(self) -> None: + if self.multiple_selection: + val = self.values[self._selected_index][0] + if val in self.current_values: + self.current_values.remove(val) + else: + self.current_values.append(val) + else: + self.current_value = self.values[self._selected_index][0] + + def _get_text_fragments(self) -> StyleAndTextTuples: + def mouse_handler(mouse_event: MouseEvent) -> None: + """ + Set `_selected_index` and `current_value` according to the y + position of the mouse click event. + """ + if mouse_event.event_type == MouseEventType.MOUSE_UP: + self._selected_index = mouse_event.position.y + self._handle_enter() + + result: StyleAndTextTuples = [] + for i, value in enumerate(self.values): + if self.multiple_selection: + checked = value[0] in self.current_values + else: + checked = value[0] == self.current_value + selected = i == self._selected_index + + style = "" + if checked: + style += " " + self.checked_style + if selected: + style += " " + self.selected_style + + result.append((style, self.open_character)) + + if selected: + result.append(("[SetCursorPosition]", "")) + + if checked: + result.append((style, "*")) + else: + result.append((style, " ")) + + result.append((style, self.close_character)) + result.append((self.default_style, " ")) + result.extend(to_formatted_text(value[1], style=self.default_style)) + result.append(("", "\n")) + + # Add mouse handler to all fragments. + for i in range(len(result)): + result[i] = (result[i][0], result[i][1], mouse_handler) + + result.pop() # Remove last newline. + return result + + def __pt_container__(self) -> Container: + return self.window + + +class RadioList(_DialogList[_T]): + """ + List of radio buttons. Only one can be checked at the same time. + + :param values: List of (value, label) tuples. + """ + + open_character = "(" + close_character = ")" + container_style = "class:radio-list" + default_style = "class:radio" + selected_style = "class:radio-selected" + checked_style = "class:radio-checked" + multiple_selection = False + + def __init__( + self, + values: Sequence[tuple[_T, AnyFormattedText]], + default: _T | None = None, + ) -> None: + if default is None: + default_values = None + else: + default_values = [default] + + super().__init__(values, default_values=default_values) + + +class CheckboxList(_DialogList[_T]): + """ + List of checkbox buttons. Several can be checked at the same time. + + :param values: List of (value, label) tuples. + """ + + open_character = "[" + close_character = "]" + container_style = "class:checkbox-list" + default_style = "class:checkbox" + selected_style = "class:checkbox-selected" + checked_style = "class:checkbox-checked" + multiple_selection = True + + +class Checkbox(CheckboxList[str]): + """Backward compatibility util: creates a 1-sized CheckboxList + + :param text: the text + """ + + show_scrollbar = False + + def __init__(self, text: AnyFormattedText = "", checked: bool = False) -> None: + values = [("value", text)] + super().__init__(values=values) + self.checked = checked + + @property + def checked(self) -> bool: + return "value" in self.current_values + + @checked.setter + def checked(self, value: bool) -> None: + if value: + self.current_values = ["value"] + else: + self.current_values = [] + + +class VerticalLine: + """ + A simple vertical line with a width of 1. + """ + + def __init__(self) -> None: + self.window = Window( + char=Border.VERTICAL, style="class:line,vertical-line", width=1 + ) + + def __pt_container__(self) -> Container: + return self.window + + +class HorizontalLine: + """ + A simple horizontal line with a height of 1. + """ + + def __init__(self) -> None: + self.window = Window( + char=Border.HORIZONTAL, style="class:line,horizontal-line", height=1 + ) + + def __pt_container__(self) -> Container: + return self.window + + +class ProgressBar: + def __init__(self) -> None: + self._percentage = 60 + + self.label = Label("60%") + self.container = FloatContainer( + content=Window(height=1), + floats=[ + # We first draw the label, then the actual progress bar. Right + # now, this is the only way to have the colors of the progress + # bar appear on top of the label. The problem is that our label + # can't be part of any `Window` below. + Float(content=self.label, top=0, bottom=0), + Float( + left=0, + top=0, + right=0, + bottom=0, + content=VSplit( + [ + Window( + style="class:progress-bar.used", + width=lambda: D(weight=int(self._percentage)), + ), + Window( + style="class:progress-bar", + width=lambda: D(weight=int(100 - self._percentage)), + ), + ] + ), + ), + ], + ) + + @property + def percentage(self) -> int: + return self._percentage + + @percentage.setter + def percentage(self, value: int) -> None: + self._percentage = value + self.label.text = f"{value}%" + + def __pt_container__(self) -> Container: + return self.container |