Repository: pavdmyt/yaspin

Issue:
Long messages or messages with new line char print on every line for each frame
Thanks for this fun lib. I noticed messages will be repeated if any of the below triggers are encountered.

[notes]
version = 1.5.0
machine = mac (catalina)

[triggers]
* very long message (longer than buffer width ie resized window)
* messages with '\n' (new line character)

[screenshot]
<img width="975" alt="Screen Shot 2021-05-11 at 6 42 14 PM" src="https://user-images.githubusercontent.com/2129902/117893898-48687100-b289-11eb-9097-8af151320fda.png">

Discussion:
@pavdmyt: Hi @cchaffatt 

The behavior triggered by `\n` symbol is kind of expected. Yaspin tries to avoid interfering with user input, otherwise handling all possible [terminal escape sequences](https://docstore.mik.ua/orelly/unix/upt/ch05_08.htm) would make code much more complicated. It is expected that yaspin receives sanitized text.

The case with long messages is interesting, I'll investigate that.

Thanks for reporting this!@sebageek: I have the same issue! Especially when I'm getting eager and drop a lot of status info in `sp.text` I pretty much get the picture above. What we could do here is figure out what the current terminal width is and then either crop or marquee the current text status.
One option that would apparently also be more or less platform independent would be [`shutil.get_terminal_size()`](https://docs.python.org/3/library/shutil.html#shutil.get_terminal_size). Cropping terminal output would increase the user experience already, but having the text as a marquee banner would be really nice, as everything would be visible then.@pavdmyt: Fixed by https://github.com/pavdmyt/yaspin/pull/240

Solution patch:
diff --git a/README.rst b/README.rst
index a5b0e98..3606e8d 100644
--- a/README.rst
+++ b/README.rst
@@ -241,6 +241,18 @@ Spinner with timer
         time.sleep(3.1415)
         sp.ok()
 
+Custom Ellipsis
+///////////////
+
+If the text does not fit in the terminal it gets truncated, you can set a custom ellipsis to signal truncation.
+
+.. code:: python
+
+   import time
+   from yaspin import yaspin
+
+   with yaspin(text="some long text", ellipsis="...") as sp:
+        time.sleep(2)
 
 Dynamic text
 ////////////
diff --git a/examples/ellipsis.py b/examples/ellipsis.py
new file mode 100644
index 0000000..fe0f24e
--- /dev/null
+++ b/examples/ellipsis.py
@@ -0,0 +1,34 @@
+"""
+examples.ellipsis
+~~~~~~~~~~~~~~~~~~~~~~
+
+Truncate text that is longer then the terminal.
+"""
+
+import time
+
+from yaspin import yaspin
+
+LONG_TEXT = (
+    "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer venenatis lectus quis tincidunt venenatis."
+    "Proin interdum neque eu lectus finibus malesuada. Nunc vel nisl quis tellus porta euismod. Aenean dignissim"
+    "tortor eu sapien gravida efficitur. Aliquam consectetur aliquam ex facilisis tincidunt. Nunc luctus arcu nec"
+    "justo gravida efficitur."
+)
+
+
+def main():
+    with yaspin(
+        text=f"With ellipsis: {LONG_TEXT}",
+        ellipsis="...",
+    ) as sp:
+        time.sleep(2)
+
+        sp.ellipsis = ""
+        sp.text = f"Without ellipsis: {LONG_TEXT}"
+
+        time.sleep(2)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/yaspin/api.py b/yaspin/api.py
index 07a847e..0c8c6cf 100644
--- a/yaspin/api.py
+++ b/yaspin/api.py
@@ -31,6 +31,8 @@ def yaspin(*args: Any, **kwargs: Any) -> Yaspin:
         sigmap (dict, optional): Maps POSIX signals to their respective
             handlers.
         timer (bool, optional): Prints a timer showing the elapsed time.
+        ellipsis (str, optional): Sets a custom ellipsis to signal text
+            truncation due to overflow.
 
     Returns:
         core.Yaspin: instance of the Yaspin class.
diff --git a/yaspin/core.py b/yaspin/core.py
index 798b5be..0766bd9 100644
--- a/yaspin/core.py
+++ b/yaspin/core.py
@@ -11,6 +11,7 @@
 
 import functools
 import itertools
+import shutil
 import signal
 import sys
 import threading
@@ -108,6 +109,7 @@ def __init__(  # pylint: disable=too-many-arguments
         side: str = "left",
         sigmap: Optional[dict[signal.Signals, SignalHandlers]] = None,
         timer: bool = False,
+        ellipsis: str = "",
     ) -> None:
         # Spinner
         self._spinner = self._set_spinner(spinner)
@@ -126,6 +128,7 @@ def __init__(  # pylint: disable=too-many-arguments
         self._side = self._set_side(side)
         self._reversal = reversal
         self._timer = timer
+        self._ellipsis = ellipsis
         self._start_time: Optional[float] = None
         self._stop_time: Optional[float] = None
 
@@ -255,6 +258,14 @@ def side(self) -> str:
     def side(self, value: str) -> None:
         self._side = self._set_side(value)
 
+    @property
+    def ellipsis(self) -> str:
+        return self._ellipsis
+
+    @ellipsis.setter
+    def ellipsis(self, value: str) -> None:
+        self._ellipsis = value
+
     @property
     def reversal(self) -> bool:
         return self._reversal
@@ -436,6 +447,19 @@ def _compose_color_func(self) -> Optional[Callable[..., str]]:
     def _compose_out(self, frame: str, mode: Optional[str] = None) -> str:
         text = str(self._text)
 
+        # Timer
+        if self._timer:
+            sec, fsec = divmod(round(100 * self.elapsed_time), 100)
+            timer = " ({}.{:02.0f})".format(  # pylint: disable=consider-using-f-string
+                timedelta(seconds=sec), fsec
+            )
+        else:
+            timer = ""
+
+        # Truncate
+        max_text_len = self._get_max_text_length(len(frame), len(timer))
+        text = text[:max_text_len] + self._ellipsis if len(text) > max_text_len else text
+
         # Colors
         if self._color_func is not None:
             frame = self._color_func(frame)
@@ -443,20 +467,23 @@ def _compose_out(self, frame: str, mode: Optional[str] = None) -> str:
         # Position
         if self._side == "right":
             frame, text = text, frame
-        # Timer
-        if self._timer:
-            sec, fsec = divmod(round(100 * self.elapsed_time), 100)
-            text += " ({}.{:02.0f})".format(  # pylint: disable=consider-using-f-string
-                timedelta(seconds=sec), fsec
-            )
+
         # Mode
         if mode is None:
-            out = f"\r{frame} {text}"
+            out = f"\r{frame} {text}{timer}"
         else:
-            out = f"{frame} {text}\n"
+            out = f"{frame} {text}{timer}\n"
 
         return out
 
+    def _get_max_text_length(self, frame_width: int, timer_width: int) -> int:
+        term_width = shutil.get_terminal_size().columns
+        ellipsis_width = len(self._ellipsis)
+        # There is always a space between frame and text
+        frame_width += 1
+
+        return term_width - frame_width - timer_width - ellipsis_width
+
     def _register_signal_handlers(self) -> None:
         # SIGKILL cannot be caught or ignored, and the receiving
         # process cannot perform any clean-up upon receiving this


Test patch:
diff --git a/tests/test_ellipsis.py b/tests/test_ellipsis.py
new file mode 100644
index 0000000..751512b
--- /dev/null
+++ b/tests/test_ellipsis.py
@@ -0,0 +1,19 @@
+import shutil
+
+from yaspin import yaspin
+
+
+def test_wo_ellipsis():
+    sp = yaspin()
+    frame, timer = 2, 10
+    max_len = shutil.get_terminal_size().columns - (frame + len(" ") + timer)
+
+    assert sp._get_max_text_length(frame, timer) == max_len
+
+
+def test_with_ellipsis():
+    ellipsis = "..."
+    sp = yaspin(ellipsis=ellipsis)
+    frame, timer = 2, 10
+    max_len = shutil.get_terminal_size().columns - (frame + len(" ") + timer + len(ellipsis))
+    assert sp._get_max_text_length(frame, timer) == max_len