diff --git a/Makefile b/Makefile index a663100bbc5..94cd6c9f430 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ unit-test: .PHONY: test-snapshot-update test-snapshot-update: - $(run) pytest --cov-report term-missing --cov=textual tests/ -vv --snapshot-update + $(run) pytest --cov-report term-missing --cov=textual tests/ -vv --snapshot-update $(PYTEST_ARGS) .PHONY: typecheck typecheck: diff --git a/docs/examples/widgets/digit_display.py b/docs/examples/widgets/digit_display.py new file mode 100644 index 00000000000..e135782bea2 --- /dev/null +++ b/docs/examples/widgets/digit_display.py @@ -0,0 +1,24 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static +from textual.widgets import DigitDisplay + + +class MyApp(App): + BINDINGS = [] + + def compose(self) -> ComposeResult: + yield Static("Digits: 0123456789") + yield DigitDisplay("0123456789") + + punctuation=" .+,XYZ^*/-=" + yield Static("Punctuation: " + punctuation) + yield DigitDisplay(punctuation) + + equation = "x = y^2 + 3.14159*y + 10" + yield Static("Equation: " + equation) + yield DigitDisplay(equation) + + +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index a66c80935c6..12c7071957f 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -40,6 +40,7 @@ from ._tooltip import Tooltip from ._tree import Tree from ._welcome import Welcome + from ._digit_display import DigitDisplay __all__ = [ @@ -76,6 +77,7 @@ "Tooltip", "Tree", "Welcome", + "DigitDisplay", ] _WIDGETS_LAZY_LOADING_CACHE: dict[str, type[Widget]] = {} diff --git a/src/textual/widgets/_digit_display.py b/src/textual/widgets/_digit_display.py new file mode 100644 index 00000000000..cb8aaa37473 --- /dev/null +++ b/src/textual/widgets/_digit_display.py @@ -0,0 +1,269 @@ +from textual.app import ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Static + + +_character_map: dict[str, str] = {} + +# in the mappings below, we use underscores to make spaces more visible, +# we will strip them out later +_character_map[ + "0" +] = """ +┏━┓ +┃╱┃ +┗━┛ +""" + +_character_map[ + "1" +] = """ +·┓· + ┃· +╺┻╸ +""" + +_character_map[ + "2" +] = """ +╺━┓ +┏━┛ +┗━╸ +""" + +_character_map[ + "3" +] = """ +╺━┓ + ━┫ +╺━┛ +""" + +_character_map[ + "4" +] = """ +╻ ╻ +┗━┫ + ╹ +""" + +_character_map[ + "5" +] = """ +┏━╸ +┗━┓ +╺━┛ +""" + +_character_map[ + "6" +] = """ +┏━╸ +┣━┓ +┗━┛ +""" + +_character_map[ + "7" +] = """ +╺━┓ + ╹ + ╹· +""" + +_character_map[ + "8" +] = """ +┏━┓ +┣━┫ +┗━┛ +""" + +_character_map[ + "9" +] = """ +┏━┓ +┗━┫ +╺━┛ +""" + +_character_map[ + " " +] = """ +··· +··· +··· +""" + + +_character_map[ + "X" +] = """ +╻ ╻ +·╋· +╹ ╹ +""" + +_character_map[ + "Y" +] = """ +╻ ╻ +·┳· +·╹· +""" + +_character_map[ + "Z" +] = """ +╺━┓ +·▞· +┗━╸ +""" + + +_character_map[ + "." +] = """ +·· +·· +·• +""" + + +_character_map[ + "," +] = """ +·· +·· +·▞ +""" + +_character_map[ + "+" +] = """ +··· +╺╋╸ +··· +""" + +_character_map[ + "-" +] = """ +··· +╺━╸ +··· +""" + +_character_map[ + "=" +] = """ +··· +╺━· +╺━· +""" + +_character_map[ + "*" +] = """ +··· +·✱· +··· +""" + + +_character_map[ + "/" +] = """ +··╻ +·▞· +╹·· +""" + +_character_map[ + "^" +] = """ +·╻· +▝·▘ +··· +""" + + +_VIRTUAL_SPACE = "·" + +# here we strip spaces and replace virtual spaces with spaces +_character_map = { + k: v.strip().replace(_VIRTUAL_SPACE, " ") for k, v in _character_map.items() +} + + +class SingleDigitDisplay(Static): + digit = reactive(" ", layout=True) + + DEFAULT_CSS = """ + SingleDigitDisplay { + height: 3; + min-width: 2; + max-width: 3; + } + """ + + def __init__(self, initial_value=" ", **kwargs): + super().__init__(**kwargs) + self.digit = initial_value + + def watch_digit(self, digit: str) -> None: + """Called when the digit attribute changes.""" + if len(digit) > 1: + raise ValueError(f"Expected a single character, got {len(digit)}") + self.update(_character_map[digit.upper()]) + + +class DigitDisplay(Widget): + """A widget to display digits and basic arithmetic operators using Unicode blocks.""" + + digits = reactive("", layout=True) + + DEFAULT_CSS = """ + DigitDisplay { + layout: horizontal; + height: 3; + } + """ + + def __init__(self, initial_value="", **kwargs): + super().__init__(**kwargs) + self._displays = [SingleDigitDisplay(d) for d in initial_value] + self.digits = initial_value + + def compose(self) -> ComposeResult: + for widget in self._displays: + yield widget + + def _add_digit_widget(self, digit: str) -> None: + new_widget = SingleDigitDisplay(digit) + self._displays.append(new_widget) + self.mount(new_widget) + + def watch_digits(self, digits: str) -> None: + """ + Called when the digits attribute changes. + Here we update the display widgets to match the input digits. + """ + diff_digits_len = len(digits) - len(self._displays) + + # Here we add or remove widgets to match the number of digits + if diff_digits_len > 0: + start = len(self._displays) + for i in range(diff_digits_len): + self._add_digit_widget(digits[start + i]) + elif diff_digits_len < 0: + for display in self._displays[diff_digits_len:]: + self._displays.remove(display) + display.remove() + + # At this point, the number of widgets matches the number of digits, and we can + # update the contents of the widgets that might need it + for i, d in enumerate(self.digits): + if self._displays[i].digit != d: + self._displays[i].digit = d diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 3dc7e522771..06f74d7fb69 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -13719,6 +13719,161 @@ ''' # --- +# name: test_digit_display + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyApp + + + + + + + + + + Digits: 0123456789 + ┏━┓ ┓ ╺━┓╺━┓╻ ╻┏━╸┏━╸╺━┓┏━┓┏━┓ + ┃╱┃ ┃ ┏━┛ ━┫┗━┫┗━┓┣━┓  ╹┣━┫┗━┫ + ┗━┛╺┻╸┗━╸╺━┛  ╹╺━┛┗━┛ ╹ ┗━┛╺━┛ + Punctuation:  .+,XYZ^*/-= + ╻ ╻╻ ╻╺━┓ ╻   ╻ + ╺╋╸ ╋  ┳  ▞ ▝ ▘ ✱  ▞ ╺━╸╺━  +  • ▞╹ ╹ ╹ ┗━╸╹  ╺━  + Equation: x = y^2 + 3.14159*y + 10 + ╻ ╻╻ ╻ ╻ ╺━┓╺━┓ ┓ ╻ ╻ ┓ ┏━╸┏━┓╻ ╻ ┓ ┏━┓ +  ╋ ╺━  ┳ ▝ ▘┏━┛╺╋╸ ━┫ ┃ ┗━┫ ┃ ┗━┓┗━┫ ✱  ┳ ╺╋╸ ┃ ┃╱┃ + ╹ ╹╺━  ╹ ┗━╸╺━┛ •╺┻╸  ╹╺┻╸╺━┛╺━┛ ╹ ╺┻╸┗━┛ + + + + + + + + + + + + + + + + + ''' +# --- # name: test_disabled_widgets ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 08553015cd1..887ecf0bacd 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -585,3 +585,6 @@ def test_notifications_through_screens(snap_compare) -> None: def test_notifications_through_modes(snap_compare) -> None: assert snap_compare(SNAPSHOT_APPS_DIR / "notification_through_modes.py") + +def test_digit_display(snap_compare) -> None: + assert snap_compare(WIDGET_EXAMPLES_DIR / "digit_display.py") diff --git a/tests/test_digit_display.py b/tests/test_digit_display.py new file mode 100644 index 00000000000..e544f5cb656 --- /dev/null +++ b/tests/test_digit_display.py @@ -0,0 +1,16 @@ +from textual.app import App, ComposeResult +from textual.widgets import DigitDisplay + + +async def test_digit_display_when_content_updated_then_display_widgets_update_accordingly(): + class DigitDisplayApp(App): + def compose(self) -> ComposeResult: + yield DigitDisplay("3.14159") + + app = DigitDisplayApp() + + async with app.run_test() as pilot: + w: DigitDisplay = app.query_one(DigitDisplay) + assert w.digits == "3.14159" + assert len(w._displays) == len(w.digits) + assert w.digits == "".join(w.digit for w in w._displays)