From c61ce5779cb70e6d07e9c8c0034582daf8d57f59 Mon Sep 17 00:00:00 2001 From: Anton Sviridov Date: Tue, 21 Apr 2026 12:08:26 +0100 Subject: [PATCH] text wrapping --- modules/core/src/main/scala/Lines.scala | 54 ++++++++ modules/core/src/main/scala/TextWrap.scala | 117 ++++++++++++++++++ modules/core/src/snapshots/core/wrap_text | 45 +++++++ modules/core/src/snapshots/coreJS/wrap_text | 45 +++++++ .../core/src/snapshots/coreNative/wrap_text | 45 +++++++ .../core/src/test/scala/TextWrapTests.scala | 42 +++++++ 6 files changed, 348 insertions(+) create mode 100644 modules/core/src/main/scala/Lines.scala create mode 100644 modules/core/src/main/scala/TextWrap.scala create mode 100644 modules/core/src/snapshots/core/wrap_text create mode 100644 modules/core/src/snapshots/coreJS/wrap_text create mode 100644 modules/core/src/snapshots/coreNative/wrap_text create mode 100644 modules/core/src/test/scala/TextWrapTests.scala diff --git a/modules/core/src/main/scala/Lines.scala b/modules/core/src/main/scala/Lines.scala new file mode 100644 index 0000000..439c0c8 --- /dev/null +++ b/modules/core/src/main/scala/Lines.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2023 Neandertech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cue4s + +private[cue4s] class Lines: + private val builder = List.newBuilder[fansi.Str] + + def +=(line: fansi.Str): Unit = + var start = 0 + var i = 0 + val len = line.length + while i < len do + val c = line.getChar(i) + if c == '\n' then + builder += line.substring(start, i) + start = i + 1 + else if c == '\r' then + builder += line.substring(start, i) + if i + 1 < len && line.getChar(i + 1) == '\n' then i += 1 + start = i + 1 + end if + i += 1 + end while + builder += line.substring(start, len) + end += + + def +=(line: String): Unit = + this += fansi.Str(line) + + def result(): List[String] = + builder.result().map(_.render) + + def result(cols: Int): List[String] = + val emptyLine = List("") + builder + .result() + .flatMap: line => + if line.length > 0 then TextWrap.greedy(line, cols).map(_.render) + else emptyLine +end Lines diff --git a/modules/core/src/main/scala/TextWrap.scala b/modules/core/src/main/scala/TextWrap.scala new file mode 100644 index 0000000..2c8e1f4 --- /dev/null +++ b/modules/core/src/main/scala/TextWrap.scala @@ -0,0 +1,117 @@ +/* + * Copyright 2023 Neandertech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cue4s + +private[cue4s] class StrBuilder private (private var internal: fansi.Str): + def clear(): Unit = + internal = fansi.Str("") + + def `++=`(other: fansi.Str): Unit = + internal ++= other + + def nonEmpty: Boolean = + internal.length > 0 + + def length: Int = + internal.length + + def `+=`(c: Char): Unit = + internal ++= fansi.Str(c.toString) + + def result() = fansi.Str(internal.render) +end StrBuilder + +private[cue4s] object StrBuilder: + def apply(): StrBuilder = new StrBuilder(fansi.Str("")) + +private[cue4s] object TextWrap: + + private[cue4s] def splitAtWhitespace(text: fansi.Str): List[fansi.Str] = + val words = List.newBuilder[fansi.Str] + var start = 0 + var inSpace = false + for + i <- 0 until text.length + char = text.getChar(i) + do + (char.isWhitespace, inSpace) match + // consuming whitespace + case (true, true) => + start += 1 + // first non whitespace character after whitespace + case (false, true) => + inSpace = false + // whitespace char after consuming + case (true, false) => + words += text.substring(start, i) + inSpace = true + start = i + 1 + case (false, false) => + end match + end for + + if start != text.length then words += text.substring(start) + + words.result() + end splitAtWhitespace + + def greedy(text: fansi.Str, maxWidth: Int): List[fansi.Str] = + val words = splitAtWhitespace( + text, + ) + if words.isEmpty then Nil + else + val result = List.newBuilder[fansi.Str] + val line = StrBuilder() + + def emitWord(word: fansi.Str): Unit = + if word.length <= maxWidth then + if line.nonEmpty && line.length + 1 + word.length > maxWidth then + result += line.result() + line.clear() + line ++= word + else + if line.nonEmpty then line += ' ' + line ++= word + else + var remaining = word + if line.nonEmpty then + val avail = maxWidth - line.length - 1 + if avail > 0 then + val (head, tail) = remaining.splitAt(avail) + line += ' ' + line ++= head + remaining = tail + end if + result += line.result() + line.clear() + end if + + while remaining.length > maxWidth do + val (head, tail) = remaining.splitAt(maxWidth) + result += head + remaining = tail + + if remaining.length > 0 then line ++= remaining + + for word <- words do emitWord(word) + + if line.nonEmpty then result += line.result() + result.result() + end if + end greedy +end TextWrap diff --git a/modules/core/src/snapshots/core/wrap_text b/modules/core/src/snapshots/core/wrap_text new file mode 100644 index 0000000..a55a3a6 --- /dev/null +++ b/modules/core/src/snapshots/core/wrap_text @@ -0,0 +1,45 @@ +ORIGINAL + +Lorem ipsum dolor sit amet, consectetur adipiscing elit.asdasdasdHello/ + +WIDTH=5 + +Lorem| +ipsum| +dolor| +sit | +amet,| +conse| +ctetu| +r adi| +pisci| +ng el| +it.as| +dasda| +sdHel| +lo/ | + +WIDTH=10 + +Lorem | +ipsum | +dolor sit | +amet, cons| +ectetur | +adipiscing| +elit.asdas| +dasdHello/| + +WIDTH=30 + +Lorem ipsum dolor sit amet, | +consectetur adipiscing | +elit.asdasdasdHello/ | + +WIDTH=100 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit.asdasdasdHello/ | + +WIDTH=500 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit.asdasdasdHello/ | \ No newline at end of file diff --git a/modules/core/src/snapshots/coreJS/wrap_text b/modules/core/src/snapshots/coreJS/wrap_text new file mode 100644 index 0000000..a55a3a6 --- /dev/null +++ b/modules/core/src/snapshots/coreJS/wrap_text @@ -0,0 +1,45 @@ +ORIGINAL + +Lorem ipsum dolor sit amet, consectetur adipiscing elit.asdasdasdHello/ + +WIDTH=5 + +Lorem| +ipsum| +dolor| +sit | +amet,| +conse| +ctetu| +r adi| +pisci| +ng el| +it.as| +dasda| +sdHel| +lo/ | + +WIDTH=10 + +Lorem | +ipsum | +dolor sit | +amet, cons| +ectetur | +adipiscing| +elit.asdas| +dasdHello/| + +WIDTH=30 + +Lorem ipsum dolor sit amet, | +consectetur adipiscing | +elit.asdasdasdHello/ | + +WIDTH=100 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit.asdasdasdHello/ | + +WIDTH=500 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit.asdasdasdHello/ | \ No newline at end of file diff --git a/modules/core/src/snapshots/coreNative/wrap_text b/modules/core/src/snapshots/coreNative/wrap_text new file mode 100644 index 0000000..a55a3a6 --- /dev/null +++ b/modules/core/src/snapshots/coreNative/wrap_text @@ -0,0 +1,45 @@ +ORIGINAL + +Lorem ipsum dolor sit amet, consectetur adipiscing elit.asdasdasdHello/ + +WIDTH=5 + +Lorem| +ipsum| +dolor| +sit | +amet,| +conse| +ctetu| +r adi| +pisci| +ng el| +it.as| +dasda| +sdHel| +lo/ | + +WIDTH=10 + +Lorem | +ipsum | +dolor sit | +amet, cons| +ectetur | +adipiscing| +elit.asdas| +dasdHello/| + +WIDTH=30 + +Lorem ipsum dolor sit amet, | +consectetur adipiscing | +elit.asdasdasdHello/ | + +WIDTH=100 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit.asdasdasdHello/ | + +WIDTH=500 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit.asdasdasdHello/ | \ No newline at end of file diff --git a/modules/core/src/test/scala/TextWrapTests.scala b/modules/core/src/test/scala/TextWrapTests.scala new file mode 100644 index 0000000..9a7ac6e --- /dev/null +++ b/modules/core/src/test/scala/TextWrapTests.scala @@ -0,0 +1,42 @@ +package cue4s + +import com.indoorvivants.snapshots.munit_integration.MunitSnapshotsIntegration + +class TextWrapTests extends munit.FunSuite, MunitSnapshotsIntegration: + + import TextWrap.greedy as wrap + + extension (lines: List[String]) + def withColumn(maxLength: Int): String = + lines + .map: l => + val (head, tail) = l.splitAt(maxLength) + head.padTo(maxLength, ' ') + "|" + tail + .mkString("\n") + + test("split") { + assertEquals( + TextWrap + .splitAtWhitespace( + fansi.Str("Lorem ipsum dolor sit amet bla "), + ) + .map(_.render), + List("Lorem", "ipsum", "dolor", "sit", "amet", "bla"), + ) + } + test("wrap text") { + val text = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.asdasdasdHello/ " + + val results = + for + width <- List(5, 10, 30, 100, 500) + wrapped = wrap(text, width).map(_.render).withColumn(width) + yield s"WIDTH=$width\n\n$wrapped" + + assertSnapshot( + "wrap text", + (s"ORIGINAL\n\n$text" :: results).mkString("\n\n"), + ) + } +end TextWrapTests