module Ruco class TextArea SURROUNDING_CHARS = { '<' => '>', '(' => ')', '[' => ']', '{' => '}', '"' => '"', "'" => "'", "`" => "`", '/' => '/' } attr_reader :lines, :selection, :column, :line def initialize(content, options) if content.respond_to?(:encoding) and content.encoding.to_s.include?('ASCII') content = content.force_encoding('UTF-8') end @lines = content.naive_split("\n") @options = options.dup @column = 0 @line = 0 @window = Window.new(@options.delete(:lines), @options.delete(:columns), @options[:window]||{}) adjust_window end def view adjust_window @window.crop(lines) * "\n" end def cursor adjust_window @window.cursor end def style_map adjust_window @window.style_map(@selection) end def move(where, *args) case where when :relative move_relative(*args) when :to self.line, self.column = args when :to_bol then move_to_bol(*args) when :to_eol then move_to_eol(*args) when :to_line then self.line = args.first when :to_column then self.column = args.first when :to_index then move(:to, *position_for_index(*args)) when :page_down self.line += @window.lines @window.set_top(@window.top + @window.lines, @lines.size) when :page_up self.line -= @window.lines @window.set_top(@window.top - @window.lines, @lines.size) when :jump move_jump(args.first) else raise "Unknown move type #{where} with #{args.inspect}" end @selection = nil unless @selecting end def selecting(&block) start = if @selection (position == @selection.first ? @selection.last : @selection.first) else position end @selecting = true instance_exec(&block) @selecting = false sorted = [start, position].sort @selection = sorted[0]..sorted[1] end def text_in_selection return '' unless @selection start = index_for_position(@selection.first) finish = index_for_position(@selection.last) content.slice(start, finish-start) end def reset @selection = nil end def insert(text) text = text.force_encoding('UTF-8') if @selection if SURROUNDING_CHARS[text] return surround_selection_with(text) else delete_content_in_selection end end text.tabs_to_spaces! if text == "\n" and @column >= current_line.leading_whitespace.size current_whitespace = current_line.leading_whitespace next_whitespace = lines[line+1].to_s.leading_whitespace text = text + [current_whitespace, next_whitespace].max end insert_into_content text move_according_to_insert text end def delete(count) if @selection delete_content_in_selection return end if count > 0 if current_line[@column..-1].size >= count current_line.slice!(@column, count) else with_lines_as_string do |content| content.slice!(index_for_position, count) end end else backspace(count.abs) end end def index_for_position(position=self.position) lines[0...position.line].sum{|l| l.size + 1} + position.column end def content (lines * "\n").freeze end def resize(lines, columns) @window.lines = lines @window.columns = columns end def position Position.new(line, column) end protected def position_for_index(index) jump = content.slice(0, index).to_s.naive_split("\n") [jump.size - 1, jump.last.size] end def with_lines_as_string string = @lines * "\n" yield string @lines = string.naive_split("\n") end def after_last_word current_line.index(/\s*$/) end def move_to_eol after_last_whitespace = current_line.size if @column == after_last_whitespace or @column < after_last_word move :to_column, after_last_word else move :to_column, after_last_whitespace end end def move_to_bol before_first_word = current_line.index(/[^\s]/) || 0 column = if @column == 0 or @column > before_first_word before_first_word else 0 end move :to_column, column end def move_relative(line_change, column_change) if line_change == 0 # let user wrap around start/end of line move :to_index, index_for_position + column_change else # normal movement self.line += line_change self.column += column_change end end def move_jump(direction) regex = /\b/m text = content next_index = if direction == :right text.index(regex, index_for_position + 1) || text.size else text.rindex(regex,[index_for_position - 1, 0].max) || 0 end move :to_index, next_index end def backspace(count) if @column >= count new_colum = @column - count current_line.slice!(new_colum, count) move :to_column, new_colum else start_index = index_for_position - count if start_index < 0 count += start_index start_index = 0 end with_lines_as_string do |content| content.slice!(start_index, count) end move :to, *position_for_index(start_index) end end def line=(x) @line = [[x, 0].max, [lines.size - 1, 0].max ].min self.column = @column # e.g. now in an empty line end def column=(x) @column = [[x, 0].max, current_line.size].min end def position=(pos) self.line, self.column = pos end def insert_into_content(text) if text.include?("\n") with_lines_as_string do |content| content.insert(index_for_position, text) end else # faster but complicated for newlines lines[line] ||= '' lines[line].insert(@column, text) end end def position_inside_content? line < lines.size and @column < lines[line].to_s.size end def current_line lines[line] || '' end def move_according_to_insert(text) inserted_lines = text.naive_split("\n") if inserted_lines.size > 1 self.line += inserted_lines.size - 1 self.column = inserted_lines.last.size else move(:relative, inserted_lines.size - 1, inserted_lines.last.size) end end def delete_content_in_selection with_lines_as_string do |content| start = index_for_position(@selection.first) finish = index_for_position(@selection.last) content.slice!(start, finish-start) move(:to, *@selection.first) end @selection = nil end def sanitize_position self.line = line self.column = column end def adjust_window sanitize_position @window.set_position(position, :max_lines => @lines.size) end def surround_selection_with(text) open_char = text close_char = SURROUNDING_CHARS[text] old_selection = @selection.deep_copy selected = text_in_selection replace_surrounding_chars = SURROUNDING_CHARS.any?{|chars| selected.surrounded_in?(*chars) } if replace_surrounding_chars selected = selected[1..-2] else old_selection.last.column += (selected.include?("\n") ? 1 : 2) end insert("#{open_char}#{selected}#{close_char}") @selection = old_selection end end end