From f48e474417dfed1b46a18e2c001ea40ccee0a668 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 19 Jul 2025 21:46:48 +0100 Subject: [PATCH 1/9] added expand rule --- src/textual/_arrange.py | 2 -- src/textual/css/_help_text.py | 18 ++++++++++++++++++ src/textual/css/_styles_builder.py | 13 +++++++++++++ src/textual/css/constants.py | 1 + src/textual/css/styles.py | 6 ++++++ src/textual/css/types.py | 1 + src/textual/widget.py | 7 +------ 7 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index a4ded9abe1..ddab9b185c 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -118,10 +118,8 @@ def arrange( ) WidgetPlacement.apply_absolute(layout_placements) - placements.extend(layout_placements) - widget.log(placements) return DockArrangeResult(placements, set(display_widgets), scroll_spacing) diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py index 2e925e0de6..50d17d004e 100644 --- a/src/textual/css/_help_text.py +++ b/src/textual/css/_help_text.py @@ -12,6 +12,7 @@ VALID_ALIGN_HORIZONTAL, VALID_ALIGN_VERTICAL, VALID_BORDER, + VALID_EXPAND, VALID_KEYLINE, VALID_LAYOUT, VALID_POSITION, @@ -788,6 +789,23 @@ def position_help_text(property_name: str) -> HelpText: ) +def expand_help_text(property_name: str) -> HelpText: + """Help text to show when the user supplies the wrong value for expand. + + Args: + property_name: The name of the property. + + Returns: + Renderable for displaying the help text for this property. + """ + return HelpText( + summary=f"Invalid value for [i]{property_name}[/]", + bullets=[ + Bullet(f"Valid values are {friendly_list(VALID_EXPAND)}"), + ], + ) + + def style_flags_property_help_text( property_name: str, value: str, context: StylingContext ) -> HelpText: diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index fe52f63903..c2234ecaae 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -16,6 +16,7 @@ border_property_help_text, color_property_help_text, dock_property_help_text, + expand_help_text, fractional_property_help_text, integer_help_text, keyline_help_text, @@ -44,6 +45,7 @@ VALID_CONSTRAIN, VALID_DISPLAY, VALID_EDGE, + VALID_EXPAND, VALID_HATCH, VALID_KEYLINE, VALID_OVERFLOW, @@ -1256,6 +1258,17 @@ def process_hatch(self, name: str, tokens: list[Token]) -> None: self.styles._rules[name] = (character or " ", color.multiply_alpha(opacity)) + def process_expand(self, name: str, tokens: list[Token]): + if not tokens: + return + if len(tokens) != 1: + self.error(name, tokens[0], offset_single_axis_help_text(name)) + else: + token = tokens[0] + if token.value not in VALID_EXPAND: + self.error(name, tokens[0], expand_help_text(name)) + self.styles._rules["expand"] = token.value + def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None: """ Returns a valid CSS property "Python" name, or None if no close matches could be found. diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index cb4f695802..d1cbae1ecf 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -87,6 +87,7 @@ VALID_HATCH: Final = {"left", "right", "cross", "vertical", "horizontal"} VALID_TEXT_WRAP: Final = {"wrap", "nowrap"} VALID_TEXT_OVERFLOW: Final = {"clip", "fold", "ellipsis"} +VALID_EXPAND: Final = {"greedy", "optimal"} HATCHES: Final = { "left": "╲", diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index b4d149aea2..6f38a37bda 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -43,6 +43,7 @@ VALID_BOX_SIZING, VALID_CONSTRAIN, VALID_DISPLAY, + VALID_EXPAND, VALID_OVERFLOW, VALID_OVERLAY, VALID_POSITION, @@ -61,6 +62,7 @@ BoxSizing, Constrain, Display, + Expand, Overflow, Overlay, ScrollbarGutter, @@ -203,6 +205,7 @@ class RulesMap(TypedDict, total=False): text_wrap: TextWrap text_overflow: TextOverflow + expand: Expand line_pad: int @@ -492,6 +495,7 @@ class StylesBase: text_overflow: StringEnumProperty[TextOverflow] = StringEnumProperty( VALID_TEXT_OVERFLOW, "fold" ) + expand: StringEnumProperty[Expand] = StringEnumProperty(VALID_EXPAND, "greedy") line_pad = IntegerProperty(default=0, layout=True) """Padding added to left and right of lines.""" @@ -1288,6 +1292,8 @@ def append_declaration(name: str, value: str) -> None: append_declaration("text-wrap", self.text_wrap) if "text_overflow" in rules: append_declaration("text-overflow", self.text_overflow) + if "expand" in rules: + append_declaration("expand", self.expand) if "line_pad" in rules: append_declaration("line-pad", str(self.line_pad)) lines.sort() diff --git a/src/textual/css/types.py b/src/textual/css/types.py index 9fda239590..d75b0c38ab 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -42,6 +42,7 @@ Position = Literal["relative", "absolute"] TextWrap = Literal["wrap", "nowrap"] TextOverflow = Literal["clip", "fold", "ellipsis"] +Expand = Literal["greedy", "expand"] Specificity3 = Tuple[int, int, int] Specificity6 = Tuple[int, int, int, int, int, int] diff --git a/src/textual/widget.py b/src/textual/widget.py index 1dbd545fe2..efb5297320 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -327,8 +327,6 @@ class Widget(DOMNode): """Rich renderable may expand beyond optimal size.""" shrink: Reactive[bool] = Reactive(True) """Rich renderable may shrink below optimal size.""" - greedy: Reactive[bool] = Reactive(True) - """Fraction widths will consume as much space as possible.""" auto_links: Reactive[bool] = Reactive(True) """Widget will highlight links automatically.""" disabled: Reactive[bool] = Reactive(False) @@ -1558,9 +1556,6 @@ def _get_box_model( The size and margin for this widget. """ styles = self.styles - # _content_width, _content_height = container - # content_width = Fraction(_content_width) - # content_height = Fraction(_content_height) is_border_box = styles.box_sizing == "border-box" gutter = styles.gutter # Padding plus border margin = styles.margin @@ -2198,7 +2193,7 @@ def _has_relative_children_width(self) -> bool: if not self.is_container: return False for child in self.children: - if not child.greedy: + if child.styles.expand == "optimal": continue styles = child.styles if styles.display == "none": From 9ffe767691df32b22cbd06abddfd8003135fbb1c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 19 Jul 2025 21:53:12 +0100 Subject: [PATCH 2/9] snapshots --- CHANGELOG.md | 5 + src/textual/css/scalar.py | 2 - .../test_collapsible_expanded.svg | 16 +- .../test_collapsible_render.svg | 8 +- .../test_snapshots/test_help_panel.svg | 144 +++++++++--------- ..._help_panel_key_display_not_duplicated.svg | 2 +- ...bindings_display_footer_and_help_panel.svg | 2 +- .../test_keymap_bindings_key_display.svg | 2 +- .../test_snapshots/test_markdown_append.svg | 4 +- ...t_markdown_component_classes_reloading.svg | 3 +- .../test_markdown_dark_theme_override.svg | 3 +- .../test_snapshots/test_markdown_example.svg | 45 +++--- .../test_markdown_light_theme_override.svg | 3 +- .../test_markdown_space_squashing.svg | 3 +- .../test_markdown_theme_switching.svg | 3 +- .../test_markdown_viewer_example.svg | 14 +- .../test_snapshots/test_tabbed_content.svg | 10 +- 17 files changed, 139 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e087ec8eca..58f21c968f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Markdown.get_stream` https://github.com/Textualize/textual/pull/5966 - Added `textual.highlight` module for syntax highlighting https://github.com/Textualize/textual/pull/5966 - Added `MessagePump.wait_for_refresh` method https://github.com/Textualize/textual/pull/5966 +- Added `Widget.container_scroll_offset` https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c +- Added `Markdown.source` attribute to MarkdownBlocks https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c +- Added extension mechanism to Markdown https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c ### Changed - Improved rendering of Markdown tables (replace Rich table with grid) which allows text selection https://github.com/Textualize/textual/pull/5962 - Change look of command palette, to drop accented borders https://github.com/Textualize/textual/pull/5966 +- Some style tweaks to Markdown https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c + ### Removed diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index e4904fff09..49b8ce9635 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -283,8 +283,6 @@ def resolve( if unit == Unit.PERCENT: unit = percent_unit - # elif unit == Unit.AUTO: - # unit = Unit.FRACTION try: dimension = RESOLVE_MAP[unit]( value, size, viewport, fraction_unit or _FRACTION_ONE diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg index e179555d6c..bd495696e4 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg @@ -202,7 +202,7 @@ - + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ ▼ Leto @@ -221,15 +221,15 @@ Bene Gesserit and concubine of Leto, and mother of Paul and Alia. - -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -▼ Paul +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +▼ Paul + - -Paul Atreides - -Son of Leto and Jessica. +Paul Atreides + +Son of Leto and Jessica. + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_render.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_render.svg index c99be136dc..af89ff09c7 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_render.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_render.svg @@ -124,7 +124,7 @@ - + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ ▼ Leto @@ -143,9 +143,9 @@ Bene Gesserit and concubine of Leto, and mother of Paul and Alia. - -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -▶ Paul +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +▶ Paul + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_help_panel.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_help_panel.svg index 0ceb6a0954..fdd267a520 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_help_panel.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_help_panel.svg @@ -19,161 +19,161 @@ font-weight: 700; } - .terminal-2718490692-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2718490692-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2718490692-r1 { fill: #121212 } -.terminal-2718490692-r2 { fill: #0178d4 } -.terminal-2718490692-r3 { fill: #4f4f4f } -.terminal-2718490692-r4 { fill: #c5c8c6 } -.terminal-2718490692-r5 { fill: #fea62b;font-weight: bold } -.terminal-2718490692-r6 { fill: #e0e0e0 } -.terminal-2718490692-r7 { fill: #000000 } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #4f4f4f } +.terminal-r4 { fill: #c5c8c6 } +.terminal-r5 { fill: #ffc473;font-weight: bold } +.terminal-r6 { fill: #e0e0e0 } +.terminal-r7 { fill: #000000 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HelpPanelApp + HelpPanelApp - + - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -         ↑Scroll Up       -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁         ↓Scroll Down     -         ←Move cursor     -left            -         →Move cursor     -right or accept -the completion  -suggestion      -   home ^aGo to start     -    end ^eGo to end       -      pgupPage Up         -      pgdnPage Down       -     ^pgupPage Left       -     ^pgdnPage Right     ▇▇ -   shift+←Move cursor     -left and select -        ^←Move cursor     -left a word     -  shift+^←Move cursor     -left a word and -select          -   shift+→Move cursor     -right and       -select          -        ^→Move cursor     -right a word    -  shift+^→Move cursor     -right a word    -and select      + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +         ↑Scroll Up       +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁         ↓Scroll Down     +         ←Move cursor     +left            +         →Move cursor     +right or accept +the completion  +suggestion      +   home ^aGo to start     +    end ^eGo to end       +      pgupPage Up         +      pgdnPage Down       +     ^pgupPage Left       +     ^pgdnPage Right     ▇▇ +   shift+←Move cursor     +left and select +        ^←Move cursor     +left a word     +  shift+^←Move cursor     +left a word and +select          +   shift+→Move cursor     +right and       +select          +        ^→Move cursor     +right a word    +  shift+^→Move cursor     +right a word    +and select      diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_help_panel_key_display_not_duplicated.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_help_panel_key_display_not_duplicated.svg index 71b310e4f0..da7835462e 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_help_panel_key_display_not_duplicated.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_help_panel_key_display_not_duplicated.svg @@ -35,7 +35,7 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #4f4f4f } .terminal-r3 { fill: #121212 } -.terminal-r4 { fill: #fea62b;font-weight: bold } +.terminal-r4 { fill: #ffc473;font-weight: bold } .terminal-r5 { fill: #e0e0e0 } .terminal-r6 { fill: #999999 } .terminal-r7 { fill: #ffa62b;font-weight: bold } diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_display_footer_and_help_panel.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_display_footer_and_help_panel.svg index 10c09b0f1d..f8035bf281 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_display_footer_and_help_panel.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_display_footer_and_help_panel.svg @@ -36,7 +36,7 @@ .terminal-r2 { fill: #c5c8c6 } .terminal-r3 { fill: #4f4f4f } .terminal-r4 { fill: #121212 } -.terminal-r5 { fill: #fea62b;font-weight: bold } +.terminal-r5 { fill: #ffc473;font-weight: bold } .terminal-r6 { fill: #999999 } .terminal-r7 { fill: #ffa62b;font-weight: bold } .terminal-r8 { fill: #495259 } diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_key_display.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_key_display.svg index 3bfb82f7ed..d75a0e7da5 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_key_display.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_key_display.svg @@ -36,7 +36,7 @@ .terminal-r2 { fill: #c5c8c6 } .terminal-r3 { fill: #4f4f4f } .terminal-r4 { fill: #121212 } -.terminal-r5 { fill: #fea62b;font-weight: bold } +.terminal-r5 { fill: #ffc473;font-weight: bold } .terminal-r6 { fill: #999999 } .terminal-r7 { fill: #ffa62b;font-weight: bold } .terminal-r8 { fill: #495259 } diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg index 272f498b0a..b36dad796f 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg @@ -34,9 +34,9 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #0178d4;font-weight: bold } -.terminal-r3 { fill: #e0e0e0;font-weight: bold } +.terminal-r3 { fill: #57a5e2 } .terminal-r4 { fill: #e0e0e0 } -.terminal-r5 { fill: #094573 } +.terminal-r5 { fill: #345b7a } diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg index 4aff474f5f..dac144f448 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg @@ -43,6 +43,7 @@ .terminal-r9 { fill: #e0e0e0;text-decoration: line-through; } .terminal-r10 { fill: #ffffff } .terminal-r11 { fill: #7dc092 } +.terminal-r12 { fill: #d2d2d2 } @@ -128,7 +129,7 @@ - + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg index b7ddb86911..929c642316 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg @@ -38,6 +38,7 @@ .terminal-r4 { fill: #ffffff } .terminal-r5 { fill: #ffc473;text-decoration: underline; } .terminal-r6 { fill: #7dc092 } +.terminal-r7 { fill: #d2d2d2 } @@ -123,7 +124,7 @@ - + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg index 42be129ae8..2206dfab90 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg @@ -35,14 +35,15 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #121212 } .terminal-r3 { fill: #0178d4;text-decoration: underline; } -.terminal-r4 { fill: #e0e0e0;font-weight: bold } +.terminal-r4 { fill: #57a5e2 } .terminal-r5 { fill: #e0e0e0 } .terminal-r6 { fill: #e0e0e0;font-style: italic; } -.terminal-r7 { fill: #ffc473 } -.terminal-r8 { fill: #000000 } -.terminal-r9 { fill: #094573 } -.terminal-r10 { fill: #0e4977 } -.terminal-r11 { fill: #124d7b } +.terminal-r7 { fill: #e0e0e0;font-weight: bold } +.terminal-r8 { fill: #ffc473 } +.terminal-r9 { fill: #000000 } +.terminal-r10 { fill: #345b7a } +.terminal-r11 { fill: #39607e } +.terminal-r12 { fill: #3d6482 } @@ -134,25 +135,25 @@ Markdown -● Typography emphasisstronginline code etc. -● Headers -● Lists -● Syntax highlighted code blocks -● Tables and more +• Typography emphasisstronginline code etc. +• Headers +• Lists +• Syntax highlighted code blocks +• Tables and more -▄▄ +▂▂ Quotes -I must not fear. - -Fear is the mind-killer. 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. - - +I must not fear. + +Fear is the mind-killer. 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. + + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg index bea99b82c2..30e0730985 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg @@ -38,6 +38,7 @@ .terminal-r4 { fill: #000000 } .terminal-r5 { fill: #a86d1c;text-decoration: underline; } .terminal-r6 { fill: #458859 } +.terminal-r7 { fill: #d2d2d2 } @@ -123,7 +124,7 @@ - + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg index c6f27c06d5..85b58d2522 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg @@ -45,6 +45,7 @@ .terminal-r11 { fill: #ffc473;font-weight: bold } .terminal-r12 { fill: #71ac84;font-style: italic; } .terminal-r13 { fill: #57a5e2 } +.terminal-r14 { fill: #d2d2d2 } @@ -130,7 +131,7 @@ - + X XX XX X X X X X diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg index bea99b82c2..30e0730985 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg @@ -38,6 +38,7 @@ .terminal-r4 { fill: #000000 } .terminal-r5 { fill: #a86d1c;text-decoration: underline; } .terminal-r6 { fill: #458859 } +.terminal-r7 { fill: #d2d2d2 } @@ -123,7 +124,7 @@ - + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg index 616ed1ae00..dff1905cab 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg @@ -41,7 +41,7 @@ .terminal-r7 { fill: #ffc473 } .terminal-r8 { fill: #000000 } .terminal-r9 { fill: #0178d4;text-decoration: underline; } -.terminal-r10 { fill: #e1e1e1;font-weight: bold } +.terminal-r10 { fill: #57a5e2 } .terminal-r11 { fill: #e0e0e0;font-style: italic; } .terminal-r12 { fill: #e0e0e0;font-weight: bold } .terminal-r13 { fill: #444444 } @@ -139,16 +139,16 @@ ├── Ⅱ Code BlocksThis is an example of Textual's MarkdownViewer └── Ⅱ Litany Against Fearwidget. -▄▄ +▃▃ Features Markdown syntax and extensions are supported. -● Typography emphasisstronginline code etc. -● Headers -● Lists (bullet and ordered) -● Syntax highlighted code blocks -● Tables! +• Typography emphasisstronginline code etc. +• Headers +• Lists (bullet and ordered) +• Syntax highlighted code blocks +• Tables! Tables diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_tabbed_content.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_tabbed_content.svg index a7631db4ce..9003724b53 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_tabbed_content.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_tabbed_content.svg @@ -127,7 +127,7 @@ - + LetoJessicaPaul ━━━━━━╸━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -137,10 +137,10 @@ Bene Gesserit and concubine of Leto, and mother of Paul and Alia. - -PaulAlia -━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -First child +PaulAlia +━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +First child + From afd642dad70c6749091a1ff4943980846886513b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 19 Jul 2025 23:05:01 +0100 Subject: [PATCH 3/9] avoid updating markdown on style change --- src/textual/css/stylesheet.py | 1 - src/textual/widgets/_markdown.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index cd22a27d94..54cb7d7bbb 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -697,7 +697,6 @@ def replace_rules( for key in modified_rule_keys: setattr(base_styles, key, get_rule(key)) - node.notify_style_update() def update(self, root: DOMNode, animate: bool = False) -> None: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index d82e854c4b..ab50388a8a 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -944,6 +944,7 @@ def __init__( self._table_of_contents: TableOfContentsType = [] self._open_links = open_links self._last_parsed_line = 0 + self._theme = "" class TableOfContentsUpdated(Message): """The table of contents was updated.""" @@ -1021,7 +1022,8 @@ def get_block_class(self, block_name: str) -> type[MarkdownBlock]: return self.BLOCKS[block_name] def notify_style_update(self) -> None: - self.update(self.source) + if self.app.theme != self._theme or self.app.debug: + self.update(self.source) super().notify_style_update() async def _on_mount(self, _: Mount) -> None: @@ -1254,6 +1256,7 @@ def update(self, markdown: str) -> AwaitComplete: Returns: An optionally awaitable object. Await this to ensure that all children have been mounted. """ + self._theme = self.app.theme parser = ( MarkdownIt("gfm-like") if self._parser_factory is None From 542e67c4cf04bbbdf62bd2918a6db49d79f4ed2b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 20 Jul 2025 08:02:11 +0100 Subject: [PATCH 4/9] list view index --- CHANGELOG.md | 1 + src/textual/widgets/_list_view.py | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f21c968f..39922fcb4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Widget.container_scroll_offset` https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c - Added `Markdown.source` attribute to MarkdownBlocks https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c - Added extension mechanism to Markdown https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c +- Added `index` to `ListView.Selected` event ### Changed diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index b8c36bdd4c..f6e1ece423 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -113,12 +113,14 @@ class Selected(Message): ALLOW_SELECTOR_MATCH = {"item"} """Additional message attributes that can be used with the [`on` decorator][textual.on].""" - def __init__(self, list_view: ListView, item: ListItem) -> None: + def __init__(self, list_view: ListView, item: ListItem, index: int) -> None: super().__init__() self.list_view: ListView = list_view """The view that contains the item selected.""" self.item: ListItem = item """The selected item.""" + self.index = index + """Index of the selected item.""" @property def control(self) -> ListView: @@ -356,7 +358,7 @@ def action_select_cursor(self) -> None: selected_child = self.highlighted_child if selected_child is None: return - self.post_message(self.Selected(self, selected_child)) + self.post_message(self.Selected(self, selected_child, self.index)) def action_cursor_down(self) -> None: """Highlight the next item in the list.""" @@ -387,7 +389,7 @@ def _on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None: event.stop() self.focus() self.index = self._nodes.index(event.item) - self.post_message(self.Selected(self, event.item)) + self.post_message(self.Selected(self, event.item, self.index)) def __len__(self) -> int: """Compute the length (in number of items) of the list view.""" From 63af2661647a7149bfd2ad7ed71793c820a83046 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 20 Jul 2025 17:22:48 +0100 Subject: [PATCH 5/9] fix source range --- src/textual/widgets/_markdown.py | 39 +++++++++++++++++++++---------- src/textual/widgets/_text_area.py | 2 +- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index ab50388a8a..3a2a452c1f 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -186,16 +186,26 @@ class MarkdownBlock(Static): } """ - def __init__(self, markdown: Markdown, token: Token, *args, **kwargs) -> None: + def __init__( + self, + markdown: Markdown, + token: Token, + source_range: tuple[int, int] | None = None, + *args, + **kwargs, + ) -> None: self._markdown: Markdown = markdown """A reference to the Markdown document that contains this block.""" self._content: Content = Content() self._token: Token = token self._blocks: list[MarkdownBlock] = [] - self.source_range: tuple[int, int] | None = ( - (token.map[0], token.map[1]) if token.map is not None else None + self.source_range: tuple[int, int] = source_range or ( + (token.map[0], token.map[1]) if token.map is not None else (0, 0) + ) + + super().__init__( + *args, name=token.type, classes=f"level-{token.level}", **kwargs ) - super().__init__(*args, **kwargs) @property def select_container(self) -> Widget: @@ -207,7 +217,7 @@ def source(self) -> str | None: if self.source_range is None: return None start, end = self.source_range - return "\n".join(self._markdown.source.splitlines()[start:end]) + return "".join(self._markdown.source.splitlines(keepends=True)[start:end]) def compose(self) -> ComposeResult: yield from self._blocks @@ -218,10 +228,6 @@ def set_content(self, content: Content) -> None: self.update(content) async def _update_from_block(self, block: MarkdownBlock) -> None: - self._token = token = block._token - self.source_range: tuple[int, int] | None = ( - (token.map[0], token.map[1]) if token.map is not None else None - ) await self.remove() await self._markdown.mount(block) @@ -254,7 +260,6 @@ def build_from_token(self, token: Token) -> None: token: The token from which this block is built. """ - self._token = token null_style = Style.null() style_stack: list[Style] = [Style()] pending_content: list[tuple[str, Style]] = [] @@ -1333,8 +1338,8 @@ def append(self, markdown: str) -> AwaitComplete: table_of_contents: TableOfContentsType = [] self._markdown = self.source + markdown - updated_source = "\n".join( - self._markdown.splitlines()[self._last_parsed_line :] + updated_source = "".join( + self._markdown.splitlines(keepends=True)[self._last_parsed_line :] ) async def await_append() -> None: @@ -1344,14 +1349,24 @@ async def await_append() -> None: existing_blocks = [ child for child in self.children if isinstance(child, MarkdownBlock) ] + start_line = self._last_parsed_line for token in reversed(tokens): if token.map is not None and token.level == 0: self._last_parsed_line += token.map[0] break + new_blocks = list(self._parse_markdown(tokens, table_of_contents)) + for block in new_blocks: + start, end = block.source_range + block.source_range = ( + start + start_line, + end + start_line, + ) + with self.app.batch_update(): if existing_blocks and new_blocks: last_block = existing_blocks[-1] + last_block.source_range = new_blocks[0].source_range try: await last_block._update_from_block(new_blocks[0]) except IndexError: diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 771f441581..6e430fb5ec 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1683,7 +1683,6 @@ def gutter_width(self) -> int: return gutter_width def _on_mount(self, event: events.Mount) -> None: - def text_selection_started(screen: Screen) -> None: """Signal callback to unselect when arbitrary text selection starts.""" self.selection = Selection(self.cursor_location, self.cursor_location) @@ -1762,6 +1761,7 @@ async def _on_paste(self, event: events.Paste) -> None: return if result := self._replace_via_keyboard(event.text, *self.selection): self.move_cursor(result.end_location) + self.focus() def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int: """Return the column that the cell width corresponds to on the given row. From 722222d52df5040029c4d6edb019505b3a4c6f3c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 21 Jul 2025 12:35:49 +0100 Subject: [PATCH 6/9] remove blank styles, refresh table correctly --- src/textual/app.py | 2 +- src/textual/content.py | 16 +++- src/textual/widget.py | 13 +++ src/textual/widgets/_markdown.py | 156 +++++++++++++++++++++++-------- src/textual/widgets/_static.py | 5 +- tests/test_markdown.py | 1 - 6 files changed, 148 insertions(+), 45 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index bca02350da..50132c6d2f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1375,7 +1375,7 @@ def _watch_theme(self, theme_name: str) -> None: self.set_class(not dark, "-light-mode", update=False) self._refresh_truecolor_filter(self.ansi_theme) self._invalidate_css() - self.call_next(self.refresh_css) + self.call_next(partial(self.refresh_css, animate=False)) self.call_next(self.theme_changed_signal.publish, theme) def _invalidate_css(self) -> None: diff --git a/src/textual/content.py b/src/textual/content.py index 63454b2335..4d7781ca56 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -126,6 +126,7 @@ def __init__( text: str = "", spans: list[Span] | None = None, cell_length: int | None = None, + get_style: Callable[[str], Style | None] | None = None, ) -> None: """ Initialize a Content object. @@ -138,6 +139,7 @@ def __init__( self._text: str = _strip_control_codes(text) self._spans: list[Span] = [] if spans is None else spans self._cell_length = cell_length + self._get_style = get_style self._optimal_width_cache: int | None = None self._minimal_width_cache: int | None = None self._height_cache: tuple[tuple[int, str, bool] | None, int] = (None, 0) @@ -335,7 +337,9 @@ def styled( if not text: return Content("") span_length = cell_len(text) if cell_length is None else cell_length - new_content = cls(text, [Span(0, span_length, style)], span_length) + new_content = cls( + text, [Span(0, span_length, style)] if style else None, span_length + ) return new_content @classmethod @@ -822,6 +826,7 @@ def iter_content() -> Iterable[Content]: extend_spans( _Span(offset + start, offset + end, style) for start, end, style in content._spans + if style ) offset += len(content._text) if total_cell_length is not None: @@ -1122,16 +1127,21 @@ def render( get_style: Callable[[str | Style], Style] if parse_style is None: - def get_style(style: str | Style) -> 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: - visual_style = Style.null() + if self._get_style is not None: + visual_style = self._get_style(style) or Style.null() + else: + visual_style = Style.null() return visual_style + get_style = _get_style + else: get_style = parse_style diff --git a/src/textual/widget.py b/src/textual/widget.py index efb5297320..40e3af4ffb 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1164,6 +1164,19 @@ def iter_styles() -> Iterable[StylesBase]: return visual_style + def _get_style(self, style: str) -> VisualStyle | None: + """A get_style method for use in Content. + + Args: + style: A style prefixed with a dot. + + Returns: + A visual style if one is fund, otherwise `None`. + """ + if style.startswith("."): + return self.get_visual_style(style[1:]) + return None + @overload def render_str(self, text_content: str) -> Content: ... diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 3a2a452c1f..f1074089d6 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -199,6 +199,7 @@ def __init__( self._content: Content = Content() self._token: Token = token self._blocks: list[MarkdownBlock] = [] + self._inline_token: Token | None = None self.source_range: tuple[int, int] = source_range or ( (token.map[0], token.map[1]) if token.map is not None else (0, 0) ) @@ -236,28 +237,35 @@ async def action_link(self, href: str) -> None: self.post_message(Markdown.LinkClicked(self._markdown, href)) # def notify_style_update(self) -> None: - # """If CSS was reloaded, try to rebuild this block from its token.""" + # # self.refresh(layout=True) + + # # """If CSS was reloaded, try to rebuild this block from its token.""" # super().notify_style_update() # self.rebuild() - # def rebuild(self) -> None: - # """Rebuild the content of the block if we have a source token.""" - # return - # if self._token is not None: - # self.build_from_token(self._token) + def rebuild(self) -> None: + """Rebuild the content of the block if we have a source token.""" + if self._inline_token is not None: + self.build_from_token(self._inline_token) def build_from_token(self, token: Token) -> None: - """Build the block content from its source token. + """Build inline block content from its source token. - This method allows the block to be rebuilt on demand, which is useful - when the styles assigned to the - [Markdown.COMPONENT_CLASSES][textual.widgets.Markdown.COMPONENT_CLASSES] - change. + Args: + token: The token from which this block is built. + """ + self._inline_token = token + content = self._token_to_content(token) + self.set_content(content) - See https://github.com/Textualize/textual/issues/3464 for more information. + def _token_to_content(self, token: Token) -> Content: + """Convert an inline token to Textual Content. Args: - token: The token from which this block is built. + token: A markdown token. + + Returns: + Content instance. """ null_style = Style.null() @@ -274,16 +282,17 @@ def add_content(text: str, style: Style) -> None: if pending_content: top_text, top_style = pending_content[-1] if top_style == style: + # Combine contiguous styles pending_content[-1] = (top_text + text, style) else: pending_content.append((text, style)) else: pending_content.append((text, style)) - get_visual_style = self._markdown.get_visual_style if token.children is None: - self.set_content(Content("")) - return + return Content("") + get_visual_style = self._markdown.get_visual_style + for child in token.children: child_type = child.type if child_type == "text": @@ -334,7 +343,7 @@ def add_content(text: str, style: Style) -> None: style_stack.pop() content = Content("").join(starmap(Content.styled, pending_content)) - self.set_content(content) + return content class MarkdownHeader(MarkdownBlock): @@ -450,6 +459,14 @@ class MarkdownParagraph(MarkdownBlock): } """ + async def _update_from_block(self, block: MarkdownBlock): + if isinstance(block, MarkdownParagraph): + self.set_content(block._content) + self._token = block._token + self._inline_token = block._inline_token + else: + await super()._update_from_block(block) + class MarkdownBlockQuote(MarkdownBlock): """A block quote Markdown block.""" @@ -631,6 +648,17 @@ def compose(self) -> ComposeResult: ).with_tooltip(cell.plain) self.last_row = row_index + def _update_content(self, headers: list[Content], rows: list[list[Content]]): + """Update cell contents.""" + self.headers = headers + self.rows = rows + cells: list[Content] = [ + *self.headers, + *[cell for row in self.rows for cell in row], + ] + for child, updated_cell in zip(self.query(MarkdownTableCellContents), cells): + child.update(updated_cell, layout=False) + async def _update_rows(self, updated_rows: list[list[Content]]) -> None: self.styles.grid_size_columns = len(self.headers) await self.query_children(f".cell.row{self.last_row}").remove() @@ -702,6 +730,34 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: rows.pop() return headers, rows + # def notify_style_update(self) -> None: + # self.call_after_refresh(self.rebuild) + + def rebuild(self) -> None: + self._rebuild() + + def _rebuild(self) -> None: + try: + table_content = self.query_one(MarkdownTableContent) + except NoMatches: + return + + def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: + for block in block._blocks: + if block._blocks: + yield from flatten(block) + yield block + + for block in flatten(self): + if block._inline_token is not None: + self.log(block._inline_token) + block.rebuild() + + headers, rows = self._get_headers_and_rows() + self._headers = headers + self._rows = rows + table_content._update_content(headers, rows) + async def _update_from_block(self, block: MarkdownBlock) -> None: """Special case to update a Markdown table. @@ -832,6 +888,12 @@ def allow_horizontal_scroll(self) -> bool: def highlight(cls, code: str, language: str) -> Content: return highlight(code, language=language) + async def _update_from_block(self, block: MarkdownBlock): + if isinstance(block, MarkdownFence): + self.set_content(block._highlighted_code) + else: + await super()._update_from_block(block) + def on_mount(self): self.set_content(self._highlighted_code) @@ -853,32 +915,33 @@ class Markdown(Widget): height: auto; padding: 0 2 0 2; layout: vertical; - color: $foreground; - # background: $surface; + color: $foreground; overflow-y: hidden; - &:focus { - background-tint: $foreground 5%; + # &:focus { + # background-tint: $foreground 5%; + # } + &:dark > .code_inline { + background: $warning 10%; + color: $text-warning 95%; } - &:dark .code_inline { - background: $warning-muted 30%; - color: $text-warning; + &:light > .code_inline { + background: $error 5%; + color: $text-error 95%; } - &:light .code_inline { - background: $error-muted 30%; - color: $text-error; + & > .em { + text-style: italic; } - } - .em { - text-style: italic; - } - .strong { - text-style: bold; - } - .s { - text-style: strike; + & > .strong { + text-style: bold; + } + & > .s { + text-style: strike; + } + } + """ COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} @@ -1027,10 +1090,18 @@ def get_block_class(self, block_name: str) -> type[MarkdownBlock]: return self.BLOCKS[block_name] def notify_style_update(self) -> None: - if self.app.theme != self._theme or self.app.debug: - self.update(self.source) super().notify_style_update() + if self.app.theme == self._theme: + return + self._theme = self.app.theme + + def rebuild_all() -> None: + for child in self.query_children(MarkdownBlock): + child.rebuild() + + self.call_after_refresh(rebuild_all) + async def _on_mount(self, _: Mount) -> None: initial_markdown = self._initial_markdown self._initial_markdown = None @@ -1252,6 +1323,15 @@ def _parse_markdown( else: yield external + def _build_from_source(self, markdown: str) -> list[MarkdownBlock]: + parser = ( + MarkdownIt("gfm-like") + if self._parser_factory is None + else self._parser_factory() + ) + tokens = parser.parse(markdown) + return list(self._parse_markdown(tokens, [])) + def update(self, markdown: str) -> AwaitComplete: """Update the document with new Markdown. diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index b3e96f4688..4ed79268e3 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -76,13 +76,14 @@ def render(self) -> RenderResult: """ return self.visual - def update(self, content: VisualType = "") -> None: + def update(self, content: VisualType = "", *, layout: bool = True) -> None: """Update the widget's content area with new text or Rich renderable. Args: content: New content. + layout: Also perform a layout operation (set to `False` if you are certain the size won't change.) """ self._content = content self._visual = visualize(self, content, markup=self._render_markup) - self.refresh(layout=True) + self.refresh(layout=layout) diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 203316f728..8493fd0492 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -112,7 +112,6 @@ async def test_softbreak_split_links_rendered_correctly() -> None: print(paragraph._content.spans) expected_spans = [ - Span(0, 8, Style()), Span(8, 20, Style.from_meta({"@click": "link('https://example.com')"})), ] print(expected_spans) From 7551bbe920cc201a63118ff2b0d05b46452e5da1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 21 Jul 2025 17:35:19 +0100 Subject: [PATCH 7/9] snapshots --- src/textual/highlight.py | 1 + src/textual/widgets/_markdown.py | 1 - .../test_snapshots/test_example_markdown.svg | 4 ++-- .../test_markdown_component_classes_reloading.svg | 13 +++++++------ .../test_snapshots/test_markdown_example.svg | 4 ++-- .../test_snapshots/test_markdown_viewer_example.svg | 4 ++-- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/textual/highlight.py b/src/textual/highlight.py index edfbd2cb20..8ec4264ab4 100644 --- a/src/textual/highlight.py +++ b/src/textual/highlight.py @@ -136,6 +136,7 @@ def highlight( styles = theme.STYLES for token_type, token in lexer.get_tokens(code): + # print(token_type, token) token_end = token_start + len(token) while True: if style := styles.get(token_type): diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index f1074089d6..713ed4920a 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -750,7 +750,6 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: for block in flatten(self): if block._inline_token is not None: - self.log(block._inline_token) block.rebuild() headers, rows = self._get_headers_and_rows() diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg index 9290cb2696..5f80f1960f 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg @@ -37,7 +37,7 @@ .terminal-r3 { fill: #a0a3a6 } .terminal-r4 { fill: #3e3e3e } .terminal-r5 { fill: #0178d4;font-weight: bold } -.terminal-r6 { fill: #ffc473 } +.terminal-r6 { fill: #f4bc6e } .terminal-r7 { fill: #0178d4;text-decoration: underline; } .terminal-r8 { fill: #e1e1e1;text-decoration: underline; } .terminal-r9 { fill: #ffa62b;font-weight: bold } @@ -128,7 +128,7 @@ - + ▼ Ⅰ Textual Markdown Browser diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg index dac144f448..591cf3f624 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg @@ -37,13 +37,14 @@ .terminal-r3 { fill: #3b3b3b } .terminal-r4 { fill: #0178d4 } .terminal-r5 { fill: #e0e0e0 } -.terminal-r6 { fill: #ffc473 } +.terminal-r6 { fill: #f4bb6e } .terminal-r7 { fill: #e0e0e0;font-weight: bold } .terminal-r8 { fill: #e0e0e0;font-style: italic; } .terminal-r9 { fill: #e0e0e0;text-decoration: line-through; } -.terminal-r10 { fill: #ffffff } -.terminal-r11 { fill: #7dc092 } -.terminal-r12 { fill: #d2d2d2 } +.terminal-r10 { fill: #ffc473 } +.terminal-r11 { fill: #ffffff } +.terminal-r12 { fill: #7dc092 } +.terminal-r13 { fill: #d2d2d2 } @@ -129,7 +130,7 @@ - + @@ -145,7 +146,7 @@ strikethrough -print("Hello, world!") +print("Hello, world!") That was some code. diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg index 2206dfab90..992787eb51 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg @@ -39,7 +39,7 @@ .terminal-r5 { fill: #e0e0e0 } .terminal-r6 { fill: #e0e0e0;font-style: italic; } .terminal-r7 { fill: #e0e0e0;font-weight: bold } -.terminal-r8 { fill: #ffc473 } +.terminal-r8 { fill: #f4bb6e } .terminal-r9 { fill: #000000 } .terminal-r10 { fill: #345b7a } .terminal-r11 { fill: #39607e } @@ -129,7 +129,7 @@ - + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg index dff1905cab..35449480ba 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg @@ -38,7 +38,7 @@ .terminal-r4 { fill: #a0a3a6 } .terminal-r5 { fill: #3e3e3e } .terminal-r6 { fill: #0178d4;font-weight: bold } -.terminal-r7 { fill: #ffc473 } +.terminal-r7 { fill: #f4bc6e } .terminal-r8 { fill: #000000 } .terminal-r9 { fill: #0178d4;text-decoration: underline; } .terminal-r10 { fill: #57a5e2 } @@ -130,7 +130,7 @@ - + ▼ Ⅰ Markdown Viewer From e12979e13e8082c6d785a1031547efe7e8b08193 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 21 Jul 2025 17:41:16 +0100 Subject: [PATCH 8/9] revert Content.get_style --- CHANGELOG.md | 3 ++- src/textual/content.py | 7 +------ src/textual/highlight.py | 1 - src/textual/widgets/_markdown.py | 3 --- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39922fcb4e..e8b1d23143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Widget.container_scroll_offset` https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c - Added `Markdown.source` attribute to MarkdownBlocks https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c - Added extension mechanism to Markdown https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c -- Added `index` to `ListView.Selected` event +- Added `index` to `ListView.Selected` event https://github.com/Textualize/textual/pull/5973 +- Added `layout` switch to Static.update https://github.com/Textualize/textual/pull/5973 ### Changed diff --git a/src/textual/content.py b/src/textual/content.py index 4d7781ca56..3910b034dd 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -126,7 +126,6 @@ def __init__( text: str = "", spans: list[Span] | None = None, cell_length: int | None = None, - get_style: Callable[[str], Style | None] | None = None, ) -> None: """ Initialize a Content object. @@ -139,7 +138,6 @@ def __init__( self._text: str = _strip_control_codes(text) self._spans: list[Span] = [] if spans is None else spans self._cell_length = cell_length - self._get_style = get_style self._optimal_width_cache: int | None = None self._minimal_width_cache: int | None = None self._height_cache: tuple[tuple[int, str, bool] | None, int] = (None, 0) @@ -1134,10 +1132,7 @@ def _get_style(style: str | Style) -> Style: try: visual_style = Style.parse(style) except Exception: - if self._get_style is not None: - visual_style = self._get_style(style) or Style.null() - else: - visual_style = Style.null() + visual_style = Style.null() return visual_style get_style = _get_style diff --git a/src/textual/highlight.py b/src/textual/highlight.py index 8ec4264ab4..edfbd2cb20 100644 --- a/src/textual/highlight.py +++ b/src/textual/highlight.py @@ -136,7 +136,6 @@ def highlight( styles = theme.STYLES for token_type, token in lexer.get_tokens(code): - # print(token_type, token) token_end = token_start + len(token) while True: if style := styles.get(token_type): diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 713ed4920a..d358f88a42 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -730,9 +730,6 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: rows.pop() return headers, rows - # def notify_style_update(self) -> None: - # self.call_after_refresh(self.rebuild) - def rebuild(self) -> None: self._rebuild() From 201c70d40a25db2a444b98af55343ae8a49a0d3f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 21 Jul 2025 18:04:57 +0100 Subject: [PATCH 9/9] removed comments --- CHANGELOG.md | 1 + src/textual/widgets/_markdown.py | 12 +----------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8b1d23143..33d6859dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed - Breaking change: Removed `Markdown.code_dark_theme`, `Markdown.code_light_theme`, `Markdown.code_indent_guides` which are no longer needed with the new code fence. https://github.com/Textualize/textual/pull/5967 +- Removed focus style from Markdown, as it can be a little expensive https://github.com/Textualize/textual/commit/e84600cfb31630f8b5493bf1043a4a1e7c212f7c ## [4.0.0] - 2025-07-12 diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index d358f88a42..e8822ffc4b 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -236,13 +236,6 @@ async def action_link(self, href: str) -> None: """Called on link click.""" self.post_message(Markdown.LinkClicked(self._markdown, href)) - # def notify_style_update(self) -> None: - # # self.refresh(layout=True) - - # # """If CSS was reloaded, try to rebuild this block from its token.""" - # super().notify_style_update() - # self.rebuild() - def rebuild(self) -> None: """Rebuild the content of the block if we have a source token.""" if self._inline_token is not None: @@ -913,10 +906,7 @@ class Markdown(Widget): layout: vertical; color: $foreground; overflow-y: hidden; - - # &:focus { - # background-tint: $foreground 5%; - # } + &:dark > .code_inline { background: $warning 10%; color: $text-warning 95%;