module Strings::Wrap

Constants

DEFAULT_WIDTH
LINE_BREAK
LINE_BREAKS
NEWLINE
SPACE

Public Class Methods

display_width(string) click to toggle source

Visible width of a string

@api private

# File lib/strings/wrap.rb, line 170
def display_width(string)
  Unicode::DisplayWidth.of(Strings::ANSI.sanitize(string))
end
format_line(text_line, wrap_at, ansi_stack) click to toggle source

Format line to be maximum of wrap_at length

@param [String] text_line

the line to format

@param [Integer] wrap_at

the maximum length to wrap the line

@return [Array]

the wrapped lines

@api private

# File lib/strings/wrap.rb, line 46
def format_line(text_line, wrap_at, ansi_stack)
  lines = []
  line  = []
  word  = []
  ansi  = []
  ansi_matched = false
  word_length = 0
  line_length = 0
  char_length = 0 # visible char length
  text_length = display_width(text_line)
  total_length = 0

  UnicodeUtils.each_grapheme(text_line) do |char|
    # we found ansi let's consume
    if char == Strings::ANSI::CSI || ansi.length > 0
      ansi << char
      if Strings::ANSI.only_ansi?(ansi.join)
        ansi_matched = true
      elsif ansi_matched
        ansi_stack << [ansi[0...-1].join, line_length + word_length]
        ansi_matched = false

        if ansi.last == Strings::ANSI::CSI
          ansi = [ansi.last]
        else
          ansi = []
        end
      end
      next if ansi.length > 0
    end

    char_length = display_width(char)
    total_length += char_length
    if line_length + word_length + char_length <= wrap_at
      if char == SPACE || total_length == text_length
        line << word.join + char
        line_length += word_length + char_length
        word = []
        word_length = 0
      else
        word << char
        word_length += char_length
      end
      next
    end

    if char == SPACE # ends with space
      lines << insert_ansi(line.join, ansi_stack)
      line = []
      line_length = 0
      word << char
      word_length += char_length
    elsif word_length + char_length <= wrap_at
      lines << insert_ansi(line.join, ansi_stack)
      line = [word.join + char]
      line_length = word_length + char_length
      word = []
      word_length = 0
    else # hyphenate word - too long to fit a line
      lines << insert_ansi(word.join, ansi_stack)
      line_length = 0
      word = [char]
      word_length = char_length
    end
  end
  lines << insert_ansi(line.join, ansi_stack) unless line.empty?
  lines << insert_ansi(word.join, ansi_stack) unless word.empty?
  lines
end
insert_ansi(string, ansi_stack = []) click to toggle source

Insert ANSI code into string

Check if there are any ANSI states, if present insert ANSI codes at given positions unwinding the stack.

@param [String] string

the string to insert ANSI codes into

@param [Array[Array[String, Integer]]] ansi_stack

the ANSI codes to apply

@return [String]

@api private

# File lib/strings/wrap.rb, line 131
def insert_ansi(string, ansi_stack = [])
  return string if ansi_stack.empty?
  return string if string.empty?

  new_stack = []
  output          = string.dup
  length          = string.size
  matched_reset   = false
  ansi_reset      = Strings::ANSI::RESET

  # Reversed so that string index don't count ansi
  ansi_stack.reverse_each do |ansi|
    if ansi[0] =~ /#{Regexp.quote(ansi_reset)}/
      matched_reset = true
      output.insert(ansi[1], ansi_reset)
      next
    elsif !matched_reset # ansi without reset
      matched_reset = false
      new_stack << ansi # keep the ansi
      next if ansi[1] == length
      if output.end_with?(NEWLINE)
        output.insert(-2, ansi_reset)
      else
        output.insert(-1, ansi_reset) # add reset at the end
      end
    end

    output.insert(ansi[1], ansi[0])
  end

  ansi_stack.replace(new_stack)

  output
end
wrap(text, wrap_at = DEFAULT_WIDTH, separator: NEWLINE) click to toggle source

Wrap a text into lines no longer than wrap_at length. Preserves existing lines and existing word boundaries.

@example

Strings::Wrap.wrap("Some longish text", 8)
# => "Some \nlongish \ntext"

@api public

# File lib/strings/wrap.rb, line 23
def wrap(text, wrap_at = DEFAULT_WIDTH, separator: NEWLINE)
  if text.scan(/[[:print:]]/).length < wrap_at.to_i || wrap_at.to_i.zero?
    return text
  end

  ansi_stack = []
  text.lines.map do |line|
    format_line(line, wrap_at, ansi_stack).join(separator)
  end.join
end

Private Instance Methods

display_width(string) click to toggle source

Visible width of a string

@api private

# File lib/strings/wrap.rb, line 170
def display_width(string)
  Unicode::DisplayWidth.of(Strings::ANSI.sanitize(string))
end
format_line(text_line, wrap_at, ansi_stack) click to toggle source

Format line to be maximum of wrap_at length

@param [String] text_line

the line to format

@param [Integer] wrap_at

the maximum length to wrap the line

@return [Array]

the wrapped lines

@api private

# File lib/strings/wrap.rb, line 46
def format_line(text_line, wrap_at, ansi_stack)
  lines = []
  line  = []
  word  = []
  ansi  = []
  ansi_matched = false
  word_length = 0
  line_length = 0
  char_length = 0 # visible char length
  text_length = display_width(text_line)
  total_length = 0

  UnicodeUtils.each_grapheme(text_line) do |char|
    # we found ansi let's consume
    if char == Strings::ANSI::CSI || ansi.length > 0
      ansi << char
      if Strings::ANSI.only_ansi?(ansi.join)
        ansi_matched = true
      elsif ansi_matched
        ansi_stack << [ansi[0...-1].join, line_length + word_length]
        ansi_matched = false

        if ansi.last == Strings::ANSI::CSI
          ansi = [ansi.last]
        else
          ansi = []
        end
      end
      next if ansi.length > 0
    end

    char_length = display_width(char)
    total_length += char_length
    if line_length + word_length + char_length <= wrap_at
      if char == SPACE || total_length == text_length
        line << word.join + char
        line_length += word_length + char_length
        word = []
        word_length = 0
      else
        word << char
        word_length += char_length
      end
      next
    end

    if char == SPACE # ends with space
      lines << insert_ansi(line.join, ansi_stack)
      line = []
      line_length = 0
      word << char
      word_length += char_length
    elsif word_length + char_length <= wrap_at
      lines << insert_ansi(line.join, ansi_stack)
      line = [word.join + char]
      line_length = word_length + char_length
      word = []
      word_length = 0
    else # hyphenate word - too long to fit a line
      lines << insert_ansi(word.join, ansi_stack)
      line_length = 0
      word = [char]
      word_length = char_length
    end
  end
  lines << insert_ansi(line.join, ansi_stack) unless line.empty?
  lines << insert_ansi(word.join, ansi_stack) unless word.empty?
  lines
end
insert_ansi(string, ansi_stack = []) click to toggle source

Insert ANSI code into string

Check if there are any ANSI states, if present insert ANSI codes at given positions unwinding the stack.

@param [String] string

the string to insert ANSI codes into

@param [Array[Array[String, Integer]]] ansi_stack

the ANSI codes to apply

@return [String]

@api private

# File lib/strings/wrap.rb, line 131
def insert_ansi(string, ansi_stack = [])
  return string if ansi_stack.empty?
  return string if string.empty?

  new_stack = []
  output          = string.dup
  length          = string.size
  matched_reset   = false
  ansi_reset      = Strings::ANSI::RESET

  # Reversed so that string index don't count ansi
  ansi_stack.reverse_each do |ansi|
    if ansi[0] =~ /#{Regexp.quote(ansi_reset)}/
      matched_reset = true
      output.insert(ansi[1], ansi_reset)
      next
    elsif !matched_reset # ansi without reset
      matched_reset = false
      new_stack << ansi # keep the ansi
      next if ansi[1] == length
      if output.end_with?(NEWLINE)
        output.insert(-2, ansi_reset)
      else
        output.insert(-1, ansi_reset) # add reset at the end
      end
    end

    output.insert(ansi[1], ansi[0])
  end

  ansi_stack.replace(new_stack)

  output
end
wrap(text, wrap_at = DEFAULT_WIDTH, separator: NEWLINE) click to toggle source

Wrap a text into lines no longer than wrap_at length. Preserves existing lines and existing word boundaries.

@example

Strings::Wrap.wrap("Some longish text", 8)
# => "Some \nlongish \ntext"

@api public

# File lib/strings/wrap.rb, line 23
def wrap(text, wrap_at = DEFAULT_WIDTH, separator: NEWLINE)
  if text.scan(/[[:print:]]/).length < wrap_at.to_i || wrap_at.to_i.zero?
    return text
  end

  ansi_stack = []
  text.lines.map do |line|
    format_line(line, wrap_at, ansi_stack).join(separator)
  end.join
end