Repository: Textualize/textual

Issue:
Add `action_switch_mode`
Following on from #2327 it would seem to make sense to add a `switch_mode` `action_` method, much like the various `Screen`-oriented `App` methods also have `action_` wrappers.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d30a40458..faf8499e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,9 +10,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 ### Added
 
 - Added App.begin_capture_print, App.end_capture_print, Widget.begin_capture_print, Widget.end_capture_print https://github.com/Textualize/textual/issues/2952
-
-### Added
-
 - Added the ability to run async methods as thread workers https://github.com/Textualize/textual/pull/2938
 
 ### Changed
@@ -24,7 +21,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Fixed a crash when a `SelectionList` had a prompt wider than itself https://github.com/Textualize/textual/issues/2900
 - Fixed a bug where `Click` events were bubbling up from `Switch` widgets https://github.com/Textualize/textual/issues/2366
 - Fixed a crash when using empty CSS variables https://github.com/Textualize/textual/issues/1849
+- Fixed issue with tabs in TextLog https://github.com/Textualize/textual/issues/3007
+
+
+### Changed
 
+- Breaking change: the default style of a `DataTable` now has `max-height: 100%` https://github.com/Textualize/textual/issues/2959
 
 ## [0.30.0] - 2023-07-17
 
diff --git a/FAQ.md b/FAQ.md
index 260bdc58a..23ff51a8a 100644
--- a/FAQ.md
+++ b/FAQ.md
@@ -265,6 +265,8 @@ If scrolling in your `DataTable` is _apparently_ broken, it may be because your
 This means that the table will be sized to fit its rows without scrolling, which may cause the *container* (typically the screen) to scroll.
 If you would like the table itself to scroll, set the height to something other than `auto`, like `100%`.
 
+**NOTE:** As of Textual v0.31.0 the `max-height` of a `DataTable` is set to `100%`, this will mean that the above is no longer the default experience.
+
 <hr>
 
 Generated by [FAQtory](https://github.com/willmcgugan/faqtory)
diff --git a/questions/datatable-doesnt-scroll.question.md b/questions/datatable-doesnt-scroll.question.md
index 7cd2ca296..e386aad1a 100644
--- a/questions/datatable-doesnt-scroll.question.md
+++ b/questions/datatable-doesnt-scroll.question.md
@@ -8,3 +8,5 @@ alt_titles:
 If scrolling in your `DataTable` is _apparently_ broken, it may be because your `DataTable` is using the default value of `height: auto`.
 This means that the table will be sized to fit its rows without scrolling, which may cause the *container* (typically the screen) to scroll.
 If you would like the table itself to scroll, set the height to something other than `auto`, like `100%`.
+
+**NOTE:** As of Textual v0.31.0 the `max-height` of a `DataTable` is set to `100%`, this will mean that the above is no longer the default experience.
diff --git a/src/textual/app.py b/src/textual/app.py
index fdc9d708d..ba8b4846d 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -2792,6 +2792,10 @@ class App(Generic[ReturnType], DOMNode):
         """An [action](/guide/actions) to remove the topmost screen and makes the new topmost screen active."""
         self.pop_screen()
 
+    async def action_switch_mode(self, mode: str) -> None:
+        """An [action](/guide/actions) that switches to the given mode.."""
+        self.switch_mode(mode)
+
     async def action_back(self) -> None:
         """An [action](/guide/actions) to go back to the previous screen (pop the current screen).
 
diff --git a/src/textual/demo.css b/src/textual/demo.css
index ec352ea21..ef0eccda0 100644
--- a/src/textual/demo.css
+++ b/src/textual/demo.css
@@ -208,6 +208,7 @@ LocationLink:hover {
 
 DataTable {
     height: 16;
+    max-height: 16;
 }
 
 
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
index 7a3d4162d..ca0cd0e88 100644
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -251,6 +251,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
         background: $surface ;
         color: $text;
         height: auto;
+        max-height: 100%;
     }
     DataTable > .datatable--header {
         text-style: bold;
diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py
index d5b24ba2c..c55d99876 100644
--- a/src/textual/widgets/_text_log.py
+++ b/src/textual/widgets/_text_log.py
@@ -91,6 +91,34 @@ class TextLog(ScrollView, can_focus=True):
     def notify_style_update(self) -> None:
         self._line_cache.clear()
 
+    def _make_renderable(self, content: RenderableType | object) -> RenderableType:
+        """Make content renderable.
+
+        Args:
+            content: Content to render.
+
+        Returns:
+            A Rich renderable.
+        """
+        renderable: RenderableType
+        if not is_renderable(content):
+            renderable = Pretty(content)
+        else:
+            if isinstance(content, str):
+                if self.markup:
+                    renderable = Text.from_markup(content)
+                else:
+                    renderable = Text(content)
+                if self.highlight:
+                    renderable = self.highlighter(renderable)
+            else:
+                renderable = cast(RenderableType, content)
+
+        if isinstance(renderable, Text):
+            renderable.expand_tabs()
+
+        return renderable
+
     def write(
         self,
         content: RenderableType | object,
@@ -114,23 +142,11 @@ class TextLog(ScrollView, can_focus=True):
 
         auto_scroll = self.auto_scroll if scroll_end is None else scroll_end
 
-        renderable: RenderableType
-        if not is_renderable(content):
-            renderable = Pretty(content)
-        else:
-            if isinstance(content, str):
-                if self.markup:
-                    renderable = Text.from_markup(content)
-                else:
-                    renderable = Text(content)
-                if self.highlight:
-                    renderable = self.highlighter(renderable)
-            else:
-                renderable = cast(RenderableType, content)
-
         console = self.app.console
         render_options = console.options
 
+        renderable = self._make_renderable(content)
+
         if isinstance(renderable, Text) and not self.wrap:
             render_options = render_options.update(overflow="ignore", no_wrap=True)
 


Test patch:
diff --git a/tests/test_textlog.py b/tests/test_textlog.py
new file mode 100644
index 000000000..3552790e2
--- /dev/null
+++ b/tests/test_textlog.py
@@ -0,0 +1,11 @@
+from rich.text import Text
+
+from textual.widgets import TextLog
+
+
+def test_make_renderable_expand_tabs():
+    # Regression test for https://github.com/Textualize/textual/issues/3007
+    text_log = TextLog()
+    renderable = text_log._make_renderable("\tfoo")
+    assert isinstance(renderable, Text)
+    assert renderable.plain == "        foo"