Repository: Textualize/rich (python)

Issue:
[BUG] Chunks of text go missing when writing Asian text (wrapping issue)
### Description

(Originally reported in Textual: https://github.com/Textualize/textual/issues/3567)

When you print Asian text (specifically Chinese and Japanese, which do not use spaces), portions of the text go missing, making it unreadable.

This seems to be related to wrapping, as the characters which go missing are at the end of a line.
Instead of being wrapped on to a new line, they disappear.

There are [rules for wrapping](https://en.m.wikipedia.org/wiki/Line_breaking_rules_in_East_Asian_languages) in these languages which would take more effort to adhere to, but at the very least, text should not go missing when printed.

### Examples

For example, running the snippet below, the `7` in `1670` disappears:

```python
from rich.console import Console
console = Console(width=20)
console.print("アプリケーションは1670万色を使用でき")
```

Output:

```
アプリケーションは16
0万色を使用でき
```

And in many cases, like those reported in this issue, multiple characters disappear:

```python
from rich.console import Console
console = Console(width=20)
console.print("TextualはPythonの高速アプリケーション開発フレームワークです")
```

Output:

```
TextualはPy
thonの高速アプリ
ケーション開発フレー
```

Notice that many of the characters at the end of the text are completely missing (`ムワークです`).


Pull request description:
Fix double-width characters disappearing when wrapping
## Type of changes

- [x] Bug fix
- [ ] New feature
- [ ] Documentation / docstrings
- [ ] Tests
- [ ] Other

## Checklist

- [x] I've run the latest [black](https://github.com/psf/black) with default args on new code.
- [x] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate.
- [x] I've added tests for new code.
- [x] I accept that @willmcgugan may be pedantic in the code review.

## Description

Update wrapping logic to fix issues with CJK charcters disappearing when the "fold" location sat *within* a double-width character. Ensure we retain browser logic of: 


> if there is no space on the current line, move to a new line, and if theres not enough space on the entire new line, fold the text over multiple lines at appropriate locations.

Adds some additional tests and docstrings, documentation etc.

Fixes #3176 

The wrapping process is overall still quite simple and doesn't match the browser in many cases. For example, wrapping does not consider punctuation (lines can begin with punctuation), and whitespace is handled differently (but practically speaking it seems sensible).

Patch:
diff --git a/.gitignore b/.gitignore
index b291d4c2..4d0a1d3a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -117,3 +117,5 @@ venv.bak/
 # airspeed velocity
 benchmarks/env/
 benchmarks/html/
+
+sandbox/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 436aed1f..a113c352 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -16,6 +16,7 @@ repos:
       - id: mixed-line-ending
       - id: check-vcs-permalinks
       - id: check-shebang-scripts-are-executable
+      - id: trailing-whitespace
   - repo: https://github.com/pre-commit/pygrep-hooks
     rev: v1.10.0
     hooks:
@@ -34,7 +35,7 @@ repos:
     hooks:
       - id: black
         exclude: ^benchmarks/
-  - repo: https://github.com/pycqa/isort
+  - repo: https://github.com/PyCQA/isort
     rev: 5.12.0
     hooks:
       - id: isort
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3562f6df..0817e86d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Fixed
 
+- Some text goes missing during wrapping when it contains double width characters https://github.com/Textualize/rich/issues/3176
 - Ensure font is correctly inherited in exported HTML https://github.com/Textualize/rich/issues/3104
+- Fixed typing for `FloatPrompt`.
 
 ## [13.6.0] - 2023-09-30
 
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index ba8759ea..22b1be0d 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -8,6 +8,7 @@ The following people have contributed to the development of Rich:
 - [Gregory Beauregard](https://github.com/GBeauregard/pyffstream)
 - [Artur Borecki](https://github.com/pufereq)
 - [Pedro Aaron](https://github.com/paaaron)
+- [Robin Bowes](https://github.com/yo61)
 - [Dennis Brakhane](https://github.com/brakhane)
 - [Darren Burns](https://github.com/darrenburns)
 - [Jim Crist-Harif](https://github.com/jcrist)
@@ -31,6 +32,7 @@ The following people have contributed to the development of Rich:
 - [Andrew Kettmann](https://github.com/akettmann)
 - [Martin Larralde](https://github.com/althonos)
 - [Hedy Li](https://github.com/hedythedev)
+- [Henry Mai](https://github.com/tanducmai)
 - [Luka Mamukashvili](https://github.com/UltraStudioLTD)
 - [Alexander Mancevice](https://github.com/amancevice)
 - [Will McGugan](https://github.com/willmcgugan)
diff --git a/docs/source/progress.rst b/docs/source/progress.rst
index 23693843..272687d9 100644
--- a/docs/source/progress.rst
+++ b/docs/source/progress.rst
@@ -58,7 +58,7 @@ The ``total`` value associated with a task is the number of steps that must be c
 Updating tasks
 ~~~~~~~~~~~~~~
 
-When you call :meth:`~rich.progress.Progress.add_task` you get back a `Task ID`. Use this ID to call :meth:`~rich.progress.Progress.update` whenever you have completed some work, or any information has changed. Typically you will need to update ``completed`` every time you have completed a step. You can do this by updated ``completed`` directly or by setting ``advance`` which will add to the current ``completed`` value.
+When you call :meth:`~rich.progress.Progress.add_task` you get back a `Task ID`. Use this ID to call :meth:`~rich.progress.Progress.update` whenever you have completed some work, or any information has changed. Typically you will need to update ``completed`` every time you have completed a step. You can do this by setting ``completed`` directly or by setting ``advance`` which will add to the current ``completed`` value.
 
 The :meth:`~rich.progress.Progress.update` method collects keyword arguments which are also associated with the task. Use this to supply any additional information you would like to render in the progress display. The additional arguments are stored in ``task.fields`` and may be referenced in :ref:`Column classes<Columns>`.
 
@@ -234,7 +234,7 @@ Here's an example that reads a url from the internet::
 
 If you expect to be reading from multiple files, you can use :meth:`~rich.progress.Progress.open` or :meth:`~rich.progress.Progress.wrap_file` to add a file progress to an existing Progress instance.
 
-See `cp_progress.py <https://github.com/willmcgugan/rich/blob/master/examples/cp_progress.py>` for a minimal clone of the ``cp`` command which shows a progress bar as the file is copied.
+See `cp_progress.py <https://github.com/willmcgugan/rich/blob/master/examples/cp_progress.py>`_ for a minimal clone of the ``cp`` command which shows a progress bar as the file is copied.
 
 
 Multiple Progress
diff --git a/docs/source/tables.rst b/docs/source/tables.rst
index fdc6ec04..f573dbc6 100644
--- a/docs/source/tables.rst
+++ b/docs/source/tables.rst
@@ -50,13 +50,13 @@ Table Options
 
 There are a number of keyword arguments on the Table constructor you can use to define how a table should look.
 
-- ``title`` Sets the title of the table (text show above the table).
-- ``caption`` Sets the table caption (text show below the table).
+- ``title`` Sets the title of the table (text shown above the table).
+- ``caption`` Sets the table caption (text shown below the table).
 - ``width`` Sets the desired width of the table (disables automatic width calculation).
 - ``min_width`` Sets a minimum width for the table.
 - ``box`` Sets one of the :ref:`appendix_box` styles for the table grid, or ``None`` for no grid.
 - ``safe_box`` Set to ``True`` to force the table to generate ASCII characters rather than unicode.
-- ``padding`` An integer, or tuple of 1, 2, or 4 values to set the padding on cells.
+- ``padding`` An integer, or tuple of 1, 2, or 4 values to set the padding on cells (see :ref:`Padding`).
 - ``collapse_padding`` If True the padding of neighboring cells will be merged.
 - ``pad_edge`` Set to False to remove padding around the edge of the table.
 - ``expand`` Set to True to expand the table to the full available size.
diff --git a/docs/source/text.rst b/docs/source/text.rst
index fd6851fb..c5a1add8 100644
--- a/docs/source/text.rst
+++ b/docs/source/text.rst
@@ -28,12 +28,12 @@ Alternatively, you can construct styled text by calling :meth:`~rich.text.Text.a
 
 If you would like to use text that is already formatted with ANSI codes, call :meth:`~rich.text.Text.from_ansi` to convert it to a ``Text`` object::
 
-    text = Text.from_ansi("\033[1mHello, World!\033[0m")
+    text = Text.from_ansi("\033[1;35mHello\033[0m, World!")
     console.print(text.spans)
 
-Since building Text instances from parts is a common requirement, Rich offers :meth:`~rich.text.Text.assemble` which will combine strings or pairs of string and Style, and return a Text instance. The follow example is equivalent to the code above::
+Since building Text instances from parts is a common requirement, Rich offers :meth:`~rich.text.Text.assemble` which will combine strings or pairs of string and Style, and return a Text instance. The following example is equivalent to the ANSI example above::
 
-    text = Text.assemble(("Hello", "bold magenta"), " World!")
+    text = Text.assemble(("Hello", "bold magenta"), ", World!")
     console.print(text)
 
 You can apply a style to given words in the text with :meth:`~rich.text.Text.highlight_words` or for ultimate control call :meth:`~rich.text.Text.highlight_regex` to highlight text matching a *regular expression*. 
diff --git a/examples/dynamic_progress.py b/examples/dynamic_progress.py
index c38da2eb..1165b4f6 100644
--- a/examples/dynamic_progress.py
+++ b/examples/dynamic_progress.py
@@ -89,7 +89,6 @@ overall_task_id = overall_progress.add_task("", total=len(apps))
 # which allows for running multiple different progress bars in parallel,
 # and dynamically showing/hiding them
 with Live(progress_group):
-
     for idx, (name, step_times) in enumerate(apps):
         # update message on overall progress bar
         top_descr = "[bold #AAAAAA](%d out of %d apps installed)" % (idx, len(apps))
diff --git a/examples/top_lite_simulator.py b/examples/top_lite_simulator.py
index 4fecaaa8..147cbb0f 100644
--- a/examples/top_lite_simulator.py
+++ b/examples/top_lite_simulator.py
@@ -53,7 +53,6 @@ def generate_process(pid: int) -> Process:
 
 
 def create_process_table(height: int) -> Table:
-
     processes = sorted(
         [generate_process(pid) for pid in range(height)],
         key=lambda p: p.cpu_percent,
diff --git a/rich/__main__.py b/rich/__main__.py
index bc55aede..8b55130f 100644
--- a/rich/__main__.py
+++ b/rich/__main__.py
@@ -207,7 +207,6 @@ Supports much of the *markdown* __syntax__!
 
 
 if __name__ == "__main__":  # pragma: no cover
-
     console = Console(
         file=io.StringIO(),
         force_terminal=True,
diff --git a/rich/_ratio.py b/rich/_ratio.py
index f7dbe927..e12397af 100644
--- a/rich/_ratio.py
+++ b/rich/_ratio.py
@@ -151,7 +151,6 @@ if __name__ == "__main__":
 
     @dataclass
     class E:
-
         size: Optional[int] = None
         ratio: int = 1
         minimum_size: int = 1
diff --git a/rich/_windows.py b/rich/_windows.py
index 98c70086..e17c5c0f 100644
--- a/rich/_windows.py
+++ b/rich/_windows.py
@@ -30,7 +30,6 @@ try:
     )
 
 except (AttributeError, ImportError, ValueError):
-
     # Fallback if we can't load the Windows DLL
     def get_windows_console_features() -> WindowsConsoleFeatures:
         features = WindowsConsoleFeatures()
diff --git a/rich/_wrap.py b/rich/_wrap.py
index c45f193f..2e94ff6f 100644
--- a/rich/_wrap.py
+++ b/rich/_wrap.py
@@ -1,5 +1,7 @@
+from __future__ import annotations
+
 import re
-from typing import Iterable, List, Tuple
+from typing import Iterable
 
 from ._loop import loop_last
 from .cells import cell_len, chop_cells
@@ -7,7 +9,11 @@ from .cells import cell_len, chop_cells
 re_word = re.compile(r"\s*\S+\s*")
 
 
-def words(text: str) -> Iterable[Tuple[int, int, str]]:
+def words(text: str) -> Iterable[tuple[int, int, str]]:
+    """Yields each word from the text as a tuple
+    containing (start_index, end_index, word). A "word" in this context may
+    include the actual word and any whitespace to the right.
+    """
     position = 0
     word_match = re_word.match(text, position)
     while word_match is not None:
@@ -17,35 +23,59 @@ def words(text: str) -> Iterable[Tuple[int, int, str]]:
         word_match = re_word.match(text, end)
 
 
-def divide_line(text: str, width: int, fold: bool = True) -> List[int]:
-    divides: List[int] = []
-    append = divides.append
-    line_position = 0
+def divide_line(text: str, width: int, fold: bool = True) -> list[int]:
+    """Given a string of text, and a width (measured in cells), return a list
+    of cell offsets which the string should be split at in order for it to fit
+    within the given width.
+
+    Args:
+        text: The text to examine.
+        width: The available cell width.
+        fold: If True, words longer than `width` will be folded onto a new line.
+
+    Returns:
+        A list of indices to break the line at.
+    """
+    break_positions: list[int] = []  # offsets to insert the breaks at
+    append = break_positions.append
+    cell_offset = 0
     _cell_len = cell_len
+
     for start, _end, word in words(text):
         word_length = _cell_len(word.rstrip())
-        if line_position + word_length > width:
+        remaining_space = width - cell_offset
+        word_fits_remaining_space = remaining_space >= word_length
+
+        if word_fits_remaining_space:
+            # Simplest case - the word fits within the remaining width for this line.
+            cell_offset += _cell_len(word)
+        else:
+            # Not enough space remaining for this word on the current line.
             if word_length > width:
+                # The word doesn't fit on any line, so we can't simply
+                # place it on the next line...
                 if fold:
-                    chopped_words = chop_cells(word, max_size=width, position=0)
-                    for last, line in loop_last(chopped_words):
+                    # Fold the word across multiple lines.
+                    folded_word = chop_cells(word, width=width)
+                    for last, line in loop_last(folded_word):
                         if start:
                             append(start)
-
                         if last:
-                            line_position = _cell_len(line)
+                            cell_offset = _cell_len(line)
                         else:
                             start += len(line)
                 else:
+                    # Folding isn't allowed, so crop the word.
                     if start:
                         append(start)
-                    line_position = _cell_len(word)
-            elif line_position and start:
+                    cell_offset = _cell_len(word)
+            elif cell_offset and start:
+                # The word doesn't fit within the remaining space on the current
+                # line, but it *can* fit on to the next (empty) line.
                 append(start)
-                line_position = _cell_len(word)
-        else:
-            line_position += _cell_len(word)
-    return divides
+                cell_offset = _cell_len(word)
+
+    return break_positions
 
 
 if __name__ == "__main__":  # pragma: no cover
@@ -53,4 +83,11 @@ if __name__ == "__main__":  # pragma: no cover
 
     console = Console(width=10)
     console.print("12345 abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWXYZ 12345")
-    print(chop_cells("abcdefghijklmnopqrstuvwxyz", 10, position=2))
+    print(chop_cells("abcdefghijklmnopqrstuvwxyz", 10))
+
+    console = Console(width=20)
+    console.rule()
+    console.print("TextualはPythonの高速アプリケーション開発フレームワークです")
+
+    console.rule()
+    console.print("アプリケーションは1670万色を使用でき")
diff --git a/rich/bar.py b/rich/bar.py
index ed86a552..022284b5 100644
--- a/rich/bar.py
+++ b/rich/bar.py
@@ -48,7 +48,6 @@ class Bar(JupyterMixin):
     def __rich_console__(
         self, console: Console, options: ConsoleOptions
     ) -> RenderResult:
-
         width = min(
             self.width if self.width is not None else options.max_width,
             options.max_width,
diff --git a/rich/box.py b/rich/box.py
index c2655002..aa4ada32 100644
--- a/rich/box.py
+++ b/rich/box.py
@@ -188,260 +188,224 @@ class Box:
         return "".join(parts)
 
 
+# fmt: off
 ASCII: Box = Box(
-    """\
-+--+
-| ||
-|-+|
-| ||
-|-+|
-|-+|
-| ||
-+--+
-""",
+    "+--+\n"
+    "| ||\n"
+    "|-+|\n"
+    "| ||\n"
+    "|-+|\n"
+    "|-+|\n"
+    "| ||\n"
+    "+--+\n",
     ascii=True,
 )
 
 ASCII2: Box = Box(
-    """\
-+-++
-| ||
-+-++
-| ||
-+-++
-+-++
-| ||
-+-++
-""",
+    "+-++\n"
+    "| ||\n"
+    "+-++\n"
+    "| ||\n"
+    "+-++\n"
+    "+-++\n"
+    "| ||\n"
+    "+-++\n",
     ascii=True,
 )
 
 ASCII_DOUBLE_HEAD: Box = Box(
-    """\
-+-++
-| ||
-+=++
-| ||
-+-++
-+-++
-| ||
-+-++
-""",
+    "+-++\n"
+    "| ||\n"
+    "+=++\n"
+    "| ||\n"
+    "+-++\n"
+    "+-++\n"
+    "| ||\n"
+    "+-++\n",
     ascii=True,
 )
 
 SQUARE: Box = Box(
-    """\
-┌─┬┐
-│ ││
-├─┼┤
-│ ││
-├─┼┤
-├─┼┤
-│ ││
-└─┴┘
-"""
+    "┌─┬┐\n"
+    "│ ││\n"
+    "├─┼┤\n"
+    "│ ││\n"
+    "├─┼┤\n"
+    "├─┼┤\n"
+    "│ ││\n"
+    "└─┴┘\n"
 )
 
 SQUARE_DOUBLE_HEAD: Box = Box(
-    """\
-┌─┬┐
-│ ││
-╞═╪╡
-│ ││
-├─┼┤
-├─┼┤
-│ ││
-└─┴┘
-"""
+    "┌─┬┐\n"
+    "│ ││\n"
+    "╞═╪╡\n"
+    "│ ││\n"
+    "├─┼┤\n"
+    "├─┼┤\n"
+    "│ ││\n"
+    "└─┴┘\n"
 )
 
 MINIMAL: Box = Box(
-    """\
-  ╷ 
-  │ 
-╶─┼╴
-  │ 
-╶─┼╴
-╶─┼╴
-  │ 
-  ╵ 
-"""
+    "  ╷ \n"
+    "  │ \n"
+    "╶─┼╴\n"
+    "  │ \n"
+    "╶─┼╴\n"
+    "╶─┼╴\n"
+    "  │ \n"
+    "  ╵ \n"
 )
 
 
 MINIMAL_HEAVY_HEAD: Box = Box(
-    """\
-  ╷ 
-  │ 
-╺━┿╸
-  │ 
-╶─┼╴
-╶─┼╴
-  │ 
-  ╵ 
-"""
+    "  ╷ \n"
+    "  │ \n"
+    "╺━┿╸\n"
+    "  │ \n"
+    "╶─┼╴\n"
+    "╶─┼╴\n"
+    "  │ \n"
+    "  ╵ \n"
 )
 
 MINIMAL_DOUBLE_HEAD: Box = Box(
-    """\
-  ╷ 
-  │ 
- ═╪ 
-  │ 
- ─┼ 
- ─┼ 
-  │ 
-  ╵ 
-"""
+    "  ╷ \n"
+    "  │ \n"
+    " ═╪ \n"
+    "  │ \n"
+    " ─┼ \n"
+    " ─┼ \n"
+    "  │ \n"
+    "  ╵ \n"
 )
 
 
 SIMPLE: Box = Box(
-    """\
-    
-    
- ── 
-    
-    
- ── 
-    
-    
-"""
+    "    \n"
+    "    \n"
+    " ── \n"
+    "    \n"
+    "    \n"
+    " ── \n"
+    "    \n"
+    "    \n"
 )
 
 SIMPLE_HEAD: Box = Box(
-    """\
-    
-    
- ── 
-    
-    
-    
-    
-    
-"""
+    "    \n"
+    "    \n"
+    " ── \n"
+    "    \n"
+    "    \n"
+    "    \n"
+    "    \n"
+    "    \n"
 )
 
 
 SIMPLE_HEAVY: Box = Box(
-    """\
-    
-    
- ━━ 
-    
-    
- ━━ 
-    
-    
-"""
+    "    \n"
+    "    \n"
+    " ━━ \n"
+    "    \n"
+    "    \n"
+    " ━━ \n"
+    "    \n"
+    "    \n"
 )
 
 
 HORIZONTALS: Box = Box(
-    """\
- ── 
-    
- ── 
-    
- ── 
- ── 
-    
- ── 
-"""
+    " ── \n"
+    "    \n"
+    " ── \n"
+    "    \n"
+    " ── \n"
+    " ── \n"
+    "    \n"
+    " ── \n"
 )
 
 ROUNDED: Box = Box(
-    """\
-╭─┬╮
-│ ││
-├─┼┤
-│ ││
-├─┼┤
-├─┼┤
-│ ││
-╰─┴╯
-"""
+    "╭─┬╮\n"
+    "│ ││\n"
+    "├─┼┤\n"
+    "│ ││\n"
+    "├─┼┤\n"
+    "├─┼┤\n"
+    "│ ││\n"
+    "╰─┴╯\n"
 )
 
 HEAVY: Box = Box(
-    """\
-┏━┳┓
-┃ ┃┃
-┣━╋┫
-┃ ┃┃
-┣━╋┫
-┣━╋┫
-┃ ┃┃
-┗━┻┛
-"""
+    "┏━┳┓\n"
+    "┃ ┃┃\n"
+    "┣━╋┫\n"
+    "┃ ┃┃\n"
+    "┣━╋┫\n"
+    "┣━╋┫\n"
+    "┃ ┃┃\n"
+    "┗━┻┛\n"
 )
 
 HEAVY_EDGE: Box = Box(
-    """\
-┏━┯┓
-┃ │┃
-┠─┼┨
-┃ │┃
-┠─┼┨
-┠─┼┨
-┃ │┃
-┗━┷┛
-"""
+    "┏━┯┓\n"
+    "┃ │┃\n"
+    "┠─┼┨\n"
+    "┃ │┃\n"
+    "┠─┼┨\n"
+    "┠─┼┨\n"
+    "┃ │┃\n"
+    "┗━┷┛\n"
 )
 
 HEAVY_HEAD: Box = Box(
-    """\
-┏━┳┓
-┃ ┃┃
-┡━╇┩
-│ ││
-├─┼┤
-├─┼┤
-│ ││
-└─┴┘
-"""
+    "┏━┳┓\n"
+    "┃ ┃┃\n"
+    "┡━╇┩\n"
+    "│ ││\n"
+    "├─┼┤\n"
+    "├─┼┤\n"
+    "│ ││\n"
+    "└─┴┘\n"
 )
 
 DOUBLE: Box = Box(
-    """\
-╔═╦╗
-║ ║║
-╠═╬╣
-║ ║║
-╠═╬╣
-╠═╬╣
-║ ║║
-╚═╩╝
-"""
+    "╔═╦╗\n"
+    "║ ║║\n"
+    "╠═╬╣\n"
+    "║ ║║\n"
+    "╠═╬╣\n"
+    "╠═╬╣\n"
+    "║ ║║\n"
+    "╚═╩╝\n"
 )
 
 DOUBLE_EDGE: Box = Box(
-    """\
-╔═╤╗
-║ │║
-╟─┼╢
-║ │║
-╟─┼╢
-╟─┼╢
-║ │║
-╚═╧╝
-"""
+    "╔═╤╗\n"
+    "║ │║\n"
+    "╟─┼╢\n"
+    "║ │║\n"
+    "╟─┼╢\n"
+    "╟─┼╢\n"
+    "║ │║\n"
+    "╚═╧╝\n"
 )
 
 MARKDOWN: Box = Box(
-    """\
-    
-| ||
-|-||
-| ||
-|-||
-|-||
-| ||
-    
-""",
+    "    \n"
+    "| ||\n"
+    "|-||\n"
+    "| ||\n"
+    "|-||\n"
+    "|-||\n"
+    "| ||\n"
+    "    \n",
     ascii=True,
 )
+# fmt: on
 
 # Map Boxes that don't render with raster fonts on to equivalent that do
 LEGACY_WINDOWS_SUBSTITUTIONS = {
@@ -464,7 +428,6 @@ PLAIN_HEADED_SUBSTITUTIONS = {
 
 
 if __name__ == "__main__":  # pragma: no cover
-
     from rich.columns import Columns
     from rich.panel import Panel
 
diff --git a/rich/cells.py b/rich/cells.py
index 9354f9e3..f85f928f 100644
--- a/rich/cells.py
+++ b/rich/cells.py
@@ -1,6 +1,8 @@
+from __future__ import annotations
+
 import re
 from functools import lru_cache
-from typing import Callable, List
+from typing import Callable
 
 from ._cell_widths import CELL_WIDTHS
 
@@ -119,33 +121,44 @@ def set_cell_size(text: str, total: int) -> str:
             start = pos
 
 
-# TODO: This is inefficient
-# TODO: This might not work with CWJ type characters
-def chop_cells(text: str, max_size: int, position: int = 0) -> List[str]:
-    """Break text in to equal (cell) length strings, returning the characters in reverse
-    order"""
+def chop_cells(
+    text: str,
+    width: int,
+) -> list[str]:
+    """Split text into lines such that each line fits within the available (cell) width.
+
+    Args:
+        text: The text to fold such that it fits in the given width.
+        width: The width available (number of cells).
+
+    Returns:
+        A list of strings such that each string in the list has cell width
+        less than or equal to the available width.
+    """
     _get_character_cell_size = get_character_cell_size
-    characters = [
-        (character, _get_character_cell_size(character)) for character in text
-    ]
-    total_size = position
-    lines: List[List[str]] = [[]]
-    append = lines[-1].append
-
-    for character, size in reversed(characters):
-        if total_size + size > max_size:
-            lines.append([character])
-            append = lines[-1].append
-            total_size = size
+    lines: list[list[str]] = [[]]
+
+    append_new_line = lines.append
+    append_to_last_line = lines[-1].append
+
+    total_width = 0
+
+    for character in text:
+        cell_width = _get_character_cell_size(character)
+        char_doesnt_fit = total_width + cell_width > width
+
+        if char_doesnt_fit:
+            append_new_line([character])
+            append_to_last_line = lines[-1].append
+            total_width = cell_width
         else:
-            total_size += size
-            append(character)
+            append_to_last_line(character)
+            total_width += cell_width
 
     return ["".join(line) for line in lines]
 
 
 if __name__ == "__main__":  # pragma: no cover
-
     print(get_character_cell_size("😽"))
     for line in chop_cells("""这是对亚洲语言支持的测试。面对模棱两可的想法，拒绝猜测的诱惑。""", 8):
         print(line)
diff --git a/rich/color.py b/rich/color.py
index dfe45593..4270a278 100644
--- a/rich/color.py
+++ b/rich/color.py
@@ -592,7 +592,6 @@ def blend_rgb(
 
 
 if __name__ == "__main__":  # pragma: no cover
-
     from .console import Console
     from .table import Table
     from .text import Text
diff --git a/rich/json.py b/rich/json.py
index 24dc5e60..f048902d 100644
--- a/rich/json.py
+++ b/rich/json.py
@@ -103,7 +103,6 @@ class JSON:
 
 
 if __name__ == "__main__":
-
     import argparse
     import sys
 
diff --git a/rich/layout.py b/rich/layout.py
index 50ebd188..7fa2852e 100644
--- a/rich/layout.py
+++ b/rich/layout.py
@@ -227,7 +227,6 @@ class Layout:
         from rich.tree import Tree
 
         def summary(layout: "Layout") -> Table:
-
             icon = layout.splitter.get_tree_icon()
 
             table = Table.grid(padding=(0, 1, 0, 0))
@@ -403,7 +402,7 @@ class Layout:
             self._render_map = render_map
             layout_lines: List[List[Segment]] = [[] for _ in range(height)]
             _islice = islice
-            for (region, lines) in render_map.values():
+            for region, lines in render_map.values():
                 _x, y, _layout_width, layout_height = region
                 for row, line in zip(
                     _islice(layout_lines, y, y + layout_height), lines
diff --git a/rich/live.py b/rich/live.py
index 3ebbbc4c..f0529a78 100644
--- a/rich/live.py
+++ b/rich/live.py
@@ -362,7 +362,7 @@ if __name__ == "__main__":  # pragma: no cover
                 table.add_column("Destination Currency")
                 table.add_column("Exchange Rate")
 
-                for ((source, dest), exchange_rate) in exchange_rate_dict.items():
+                for (source, dest), exchange_rate in exchange_rate_dict.items():
                     table.add_row(
                         source,
                         dest,
diff --git a/rich/live_render.py b/rich/live_render.py
index f6fa7b2d..4284cccc 100644
--- a/rich/live_render.py
+++ b/rich/live_render.py
@@ -82,7 +82,6 @@ class LiveRender:
     def __rich_console__(
         self, console: Console, options: ConsoleOptions
     ) -> RenderResult:
-
         renderable = self.renderable
         style = console.get_style(self.style)
         lines = console.render_lines(renderable, options, style=style, pad=False)
diff --git a/rich/pretty.py b/rich/pretty.py
index 498907f4..5c48cfd9 100644
--- a/rich/pretty.py
+++ b/rich/pretty.py
@@ -986,7 +986,7 @@ if __name__ == "__main__":  # pragma: no cover
 
     from rich import print
 
-    # print(Pretty(data, indent_guides=True, max_string=20))
+    print(Pretty(data, indent_guides=True, max_string=20))
 
     class Thing:
         def __repr__(self) -> str:
diff --git a/rich/progress_bar.py b/rich/progress_bar.py
index 67361df2..a2bf3261 100644
--- a/rich/progress_bar.py
+++ b/rich/progress_bar.py
@@ -156,7 +156,6 @@ class ProgressBar(JupyterMixin):
     def __rich_console__(
         self, console: Console, options: ConsoleOptions
     ) -> RenderResult:
-
         width = min(self.width or options.max_width, options.max_width)
         ascii = options.legacy_windows or options.ascii_only
         should_pulse = self.pulse or self.total is None
diff --git a/rich/prompt.py b/rich/prompt.py
index 064c959b..972082b7 100644
--- a/rich/prompt.py
+++ b/rich/prompt.py
@@ -307,7 +307,7 @@ class IntPrompt(PromptBase[int]):
     validate_error_message = "[prompt.invalid]Please enter a valid integer number"
 
 
-class FloatPrompt(PromptBase[int]):
+class FloatPrompt(PromptBase[float]):
     """A prompt that returns a float.
 
     Example:
@@ -346,7 +346,6 @@ class Confirm(PromptBase[bool]):
 
 
 if __name__ == "__main__":  # pragma: no cover
-
     from rich import print
 
     if Confirm.ask("Run [i]prompt[/i] tests?", default=True):
diff --git a/rich/segment.py b/rich/segment.py
index 9bcfc7c5..603d5097 100644
--- a/rich/segment.py
+++ b/rich/segment.py
@@ -109,7 +109,6 @@ class Segment(NamedTuple):
     @classmethod
     @lru_cache(1024 * 16)
     def _split_cells(cls, segment: "Segment", cut: int) -> Tuple["Segment", "Segment"]:
-
         text, style, control = segment
         _Segment = Segment
 
diff --git a/rich/status.py b/rich/status.py
index 09eff405..65744838 100644
--- a/rich/status.py
+++ b/rich/status.py
@@ -107,7 +107,6 @@ class Status(JupyterMixin):
 
 
 if __name__ == "__main__":  # pragma: no cover
-
     from time import sleep
 
     from .console import Console
diff --git a/rich/table.py b/rich/table.py
index 578d3d63..5fc5ace0 100644
--- a/rich/table.py
+++ b/rich/table.py
@@ -212,7 +212,6 @@ class Table(JupyterMixin):
         caption_justify: "JustifyMethod" = "center",
         highlight: bool = False,
     ) -> None:
-
         self.columns: List[Column] = []
         self.rows: List[Row] = []
         self.title = title
@@ -471,7 +470,6 @@ class Table(JupyterMixin):
     def __rich_console__(
         self, console: "Console", options: "ConsoleOptions"
     ) -> "RenderResult":
-
         if not self.columns:
             yield Segment("\n")
             return
@@ -685,7 +683,7 @@ class Table(JupyterMixin):
                     getattr(renderable, "vertical", None) or column.vertical,
                 )
         else:
-            for (style, renderable) in raw_cells:
+            for style, renderable in raw_cells:
                 yield _Cell(
                     style,
                     renderable,
diff --git a/rich/traceback.py b/rich/traceback.py
index 341f7f41..821c7501 100644
--- a/rich/traceback.py
+++ b/rich/traceback.py
@@ -636,7 +636,6 @@ class Traceback:
 
         excluded = False
         for frame_index, frame in enumerate(stack.frames):
-
             if exclude_frames and frame_index in exclude_frames:
                 excluded = True
                 continue
@@ -720,7 +719,6 @@ class Traceback:
 
 
 if __name__ == "__main__":  # pragma: no cover
-
     from .console import Console
 
     console = Console()
@@ -744,7 +742,6 @@ if __name__ == "__main__":  # pragma: no cover
         bar(a)
 
     def error() -> None:
-
         try:
             try:
                 foo(0)
diff --git a/rich/tree.py b/rich/tree.py
index b9b7bbe5..8c5e7181 100644
--- a/rich/tree.py
+++ b/rich/tree.py
@@ -72,7 +72,6 @@ class Tree(JupyterMixin):
     def __rich_console__(
         self, console: "Console", options: "ConsoleOptions"
     ) -> "RenderResult":
-
         stack: List[Iterator[Tuple[bool, Tree]]] = []
         pop = stack.pop
         push = stack.append
@@ -195,7 +194,6 @@ class Tree(JupyterMixin):
 
 
 if __name__ == "__main__":  # pragma: no cover
-
     from rich.console import Group
     from rich.markdown import Markdown
     from rich.panel import Panel
diff --git a/tools/make_terminal_widths.py b/tools/make_terminal_widths.py
index 850f67a9..1e556834 100644
--- a/tools/make_terminal_widths.py
+++ b/tools/make_terminal_widths.py
@@ -40,7 +40,6 @@ def make_widths_table() -> List[Tuple[int, int, int]]:
 
 
 def get_cell_size(table: List[Tuple[int, int, int]], character: str) -> int:
-
     codepoint = ord(character)
     lower_bound = 0
     upper_bound = len(table) - 1


Test patch:
diff --git a/tests/test_align.py b/tests/test_align.py
index 5f67dcbc..9abf3e2d 100644
--- a/tests/test_align.py
+++ b/tests/test_align.py
@@ -8,7 +8,6 @@ from rich.measure import Measurement
 
 
 def test_bad_align_legal():
-
     # Legal
     Align("foo", "left")
     Align("foo", "center")
diff --git a/tests/test_cells.py b/tests/test_cells.py
index 50fa5219..fe451fa5 100644
--- a/tests/test_cells.py
+++ b/tests/test_cells.py
@@ -1,4 +1,5 @@
 from rich import cells
+from rich.cells import chop_cells
 
 
 def test_cell_len_long_string():
@@ -40,3 +41,21 @@ def test_set_cell_size_infinite():
             )
             == size
         )
+
+
+def test_chop_cells():
+    """Simple example of splitting cells into lines of width 3."""
+    text = "abcdefghijk"
+    assert chop_cells(text, 3) == ["abc", "def", "ghi", "jk"]
+
+
+def test_chop_cells_double_width_boundary():
+    """The available width lies within a double-width character."""
+    text = "ありがとう"
+    assert chop_cells(text, 3) == ["あ", "り", "が", "と", "う"]
+
+
+def test_chop_cells_mixed_width():
+    """Mixed single and double-width characters."""
+    text = "あ1り234が5と6う78"
+    assert chop_cells(text, 3) == ["あ1", "り2", "34", "が5", "と6", "う7", "8"]
diff --git a/tests/test_color.py b/tests/test_color.py
index 796f0198..010d7974 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -124,7 +124,6 @@ def test_get_ansi_codes() -> None:
 
 
 def test_downgrade() -> None:
-
     assert Color.parse("color(9)").downgrade(0) == Color(
         "color(9)", ColorType.STANDARD, 9, None
     )
diff --git a/tests/test_file_proxy.py b/tests/test_file_proxy.py
index 51c530aa..2ec4d789 100644
--- a/tests/test_file_proxy.py
+++ b/tests/test_file_proxy.py
@@ -28,7 +28,6 @@ def test_flush():
 
 
 def test_new_lines():
-
     file = io.StringIO()
     console = Console(file=file)
     file_proxy = FileProxy(console, file)
diff --git a/tests/test_live.py b/tests/test_live.py
index 8e3d5892..f037e4b8 100644
--- a/tests/test_live.py
+++ b/tests/test_live.py
@@ -22,7 +22,6 @@ def create_capture_console(
 
 
 def test_live_state() -> None:
-
     with Live("") as live:
         assert live._started
         live.start()
diff --git a/tests/test_progress.py b/tests/test_progress.py
index 6a336d34..72497a0c 100644
--- a/tests/test_progress.py
+++ b/tests/test_progress.py
@@ -150,7 +150,6 @@ def test_spinner_column():
 
 
 def test_download_progress_uses_decimal_units() -> None:
-
     column = DownloadColumn()
     test_task = Task(1, "test", 1000, 500, _get_time=lambda: 1.0)
     rendered_progress = str(column.render(test_task))
@@ -159,7 +158,6 @@ def test_download_progress_uses_decimal_units() -> None:
 
 
 def test_download_progress_uses_binary_units() -> None:
-
     column = DownloadColumn(binary_units=True)
     test_task = Task(1, "test", 1024, 512, _get_time=lambda: 1.0)
     rendered_progress = str(column.render(test_task))
@@ -280,7 +278,6 @@ def test_render() -> None:
 
 
 def test_track() -> None:
-
     console = Console(
         file=io.StringIO(),
         force_terminal=True,
@@ -339,7 +336,6 @@ def test_progress_track() -> None:
 
 
 def test_columns() -> None:
-
     console = Console(
         file=io.StringIO(),
         force_terminal=True,
diff --git a/tests/test_stack.py b/tests/test_stack.py
index 5bbb885d..e961ddd8 100644
--- a/tests/test_stack.py
+++ b/tests/test_stack.py
@@ -2,7 +2,6 @@ from rich._stack import Stack
 
 
 def test_stack():
-
     stack = Stack()
     stack.push("foo")
     stack.push("bar")
diff --git a/tests/test_status.py b/tests/test_status.py
index 43ef94e0..c2aec504 100644
--- a/tests/test_status.py
+++ b/tests/test_status.py
@@ -6,7 +6,6 @@ from rich.status import Status
 
 
 def test_status():
-
     console = Console(
         color_system=None, width=80, legacy_windows=False, get_time=lambda: 0.0
     )
diff --git a/tests/test_table.py b/tests/test_table.py
index 4faf9d53..2eea1897 100644
--- a/tests/test_table.py
+++ b/tests/test_table.py
@@ -114,7 +114,6 @@ def test_init_append_column():
 
 
 def test_rich_measure():
-
     console = Console()
     assert Table("test_header", width=-1).__rich_measure__(
         console, console.options
diff --git a/tests/test_text.py b/tests/test_text.py
index 22c34e44..18c91cff 100644
--- a/tests/test_text.py
+++ b/tests/test_text.py
@@ -449,6 +449,20 @@ def test_wrap_cjk_width_mid_character():
     ]
 
 
+def test_wrap_cjk_mixed():
+    """Regression test covering https://github.com/Textualize/rich/issues/3176 and
+    https://github.com/Textualize/textual/issues/3567 - double width characters could
+    result in text going missing when wrapping."""
+    text = Text("123ありがとうございました")
+    console = Console(width=20)  # let's ensure the width passed to wrap() wins.
+
+    wrapped_lines = text.wrap(console, width=8)
+    with console.capture() as capture:
+        console.print(wrapped_lines)
+
+    assert capture.get() == "123あり\nがとうご\nざいまし\nた\n"
+
+
 def test_wrap_long():
     text = Text("abracadabra", justify="left")
     lines = text.wrap(Console(), 4)
@@ -497,6 +511,47 @@ def test_wrap_long_words_2():
     ]
 
 
+def test_wrap_long_words_followed_by_other_words():
+    """After folding a word across multiple lines, we should continue from
+    the next word immediately after the folded word (don't take a newline
+    following completion of the folded word)."""
+    text = Text("123 12345678 123 123")
+    lines = text.wrap(Console(), 6)
+    assert lines._lines == [
+        Text("123 "),
+        Text("123456"),
+        Text("78 123"),
+        Text("123"),
+    ]
+
+
+def test_wrap_long_word_preceeded_by_word_of_full_line_length():
+    """The width of the first word is the same as the available width.
+    Ensures that folding works correctly when there's no space available
+    on the current line."""
+    text = Text("123456 12345678 123 123")
+    lines = text.wrap(Console(), 6)
+    assert lines._lines == [
+        Text("123456"),
+        Text("123456"),
+        Text("78 123"),
+        Text("123"),
+    ]
+
+
+def test_wrap_multiple_consecutive_spaces():
+    """Adding multiple consecutive spaces at the end of a line does not impact
+    the location at which a break will be added during the process of wrapping."""
+    text = Text("123456    12345678 123 123")
+    lines = text.wrap(Console(), 6)
+    assert lines._lines == [
+        Text("123456"),
+        Text("123456"),
+        Text("78 123"),
+        Text("123"),
+    ]
+
+
 def test_wrap_long_words_justify_left():
     text = Text("X 123456789", justify="left")
     lines = text.wrap(Console(), 4)
@@ -508,6 +563,17 @@ def test_wrap_long_words_justify_left():
     assert lines[3] == Text("9   ")
 
 
+def test_wrap_leading_and_trailing_whitespace():
+    text = Text("   123  456 789   ")
+    lines = text.wrap(Console(), 4)
+    assert lines._lines == [
+        Text("   1"),
+        Text("23  "),
+        Text("456 "),
+        Text("789 "),
+    ]
+
+
 def test_no_wrap_no_crop():
     text = Text("Hello World!" * 3)
 
diff --git a/tests/test_traceback.py b/tests/test_traceback.py
index 11b623dd..cf24b0cc 100644
--- a/tests/test_traceback.py
+++ b/tests/test_traceback.py
@@ -345,7 +345,6 @@ def test_rich_traceback_omit_optional_local_flag(
 
 
 if __name__ == "__main__":  # pragma: no cover
-
     expected = render(get_exception())
 
     with open("_exception_render.py", "wt") as fh: