AndyObtiva/glimmer-dsl-tk

View on GitHub
lib/glimmer/tk/text_proxy.rb

Summary

Maintainability
F
3 days
Test Coverage
# Copyright (c) 2020-2022 Andy Maleh
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

require 'glimmer/tk/widget_proxy'

module Glimmer
  module Tk
    # Proxy for Tk::Text
    #
    # Follows the Proxy Design Pattern
    class TextProxy < WidgetProxy
      ALL_TAG = '__all__'
      FORMAT_DEFAULT_MAP = {
        'justify' => 'left',
      }
    
      def handle_listener(listener_name, &listener)
        case listener_name.to_s.downcase
        when '<<modified>>', '<modified>', 'modified'
          modified_listener = Proc.new do |*args|
            @modified_count ||= 0
            @modified_count += 1
            listener.call(*args)
            @insert_mark_moved_proc&.call
            @tk.modified = false
          end
          @tk.bind('<Modified>', modified_listener)
        when '<<selection>>', '<selection>', 'selection'
          @tk.bind('<Selection>', listener)
        when 'destroy'
          super
        when 'insertmarkmove', 'insertmarkmoved', 'insert_mark_move', 'insert_mark_moved'
          if @insert_mark_moved_proc.nil?
            handle_listener('KeyPress') do |event|
              @insert_mark_moved_proc&.call
            end
            handle_listener('KeyRelease') do |event|
              @insert_mark_moved_proc&.call
            end
            handle_listener('ButtonPress') do |event|
              @insert_mark_moved_proc&.call
            end
            handle_listener('ButtonRelease') do |event|
              @insert_mark_moved_proc&.call
            end
          end
          @insert_mark = @tk.index('insert')
          @insert_mark_moved_proc = Proc.new do
            new_insert_mark = @tk.index('insert')
            if new_insert_mark != @insert_mark
              @insert_mark = new_insert_mark
              listener.call(new_insert_mark)
            end
          end
        else
          super
        end
      end
      
      def edit_undo
        # <Modified> fires twice the first time, which is equivalent to one change.
        if @modified_count.to_i > 2
          # must count the extra 2 modified count that will occur upon undo too
          @modified_count -= 4
          @tk.edit_undo
        end
      end
      
      def edit_redo
        begin
          @tk.edit_redo
        rescue => e
          # No Op
        end
      end
      
      def add_selection_format(option, value, no_selection_default: :insert_word, focus: true)
        process_selection_ranges(no_selection_default: no_selection_default, focus: focus) { |range_start, range_end| add_format(range_start, range_end, option, value) }
      end
      
      def remove_selection_format(option, value, no_selection_default: :insert_word, focus: true)
        process_selection_ranges(no_selection_default: no_selection_default, focus: focus) { |range_start, range_end| remove_format(range_start, range_end, option, value) }
      end
      
      def toggle_selection_format(option, value, no_selection_default: :insert_word, focus: true)
        process_selection_ranges(no_selection_default: no_selection_default, focus: focus) { |range_start, range_end| toggle_format(range_start, range_end, option, value) }
      end
      
      def add_selection_font_format(option, value, no_selection_default: :insert_word, focus: true)
        process_selection_ranges(no_selection_default: no_selection_default, focus: focus) { |range_start, range_end| add_font_format(range_start, range_end, option, value) }
      end
      
      def remove_selection_font_format(option, value, no_selection_default: :insert_word, focus: true)
        process_selection_ranges(no_selection_default: no_selection_default, focus: focus) { |range_start, range_end| remove_font_format(range_start, range_end, option, value) }
      end
      
      def toggle_selection_font_format(option, value, no_selection_default: :insert_word, focus: true)
        process_selection_ranges(no_selection_default: no_selection_default, focus: focus) { |range_start, range_end| toggle_font_format(range_start, range_end, option, value) }
      end
      
      def process_selection_ranges(no_selection_default: :insert_word, focus: true, &processor)
        regions = @tk.tag_ranges('sel')
        if regions.empty?
          case no_selection_default
          when :insert_word
            regions = [[@tk.index('insert wordstart'), @tk.index('insert wordend + 1 char')]]
          when :insert_letter
            regions = [[@tk.index('insert'), @tk.index('insert + 1 char')]]
          end
        end
        regions.each do |region|
          range_start = region.first
          range_end = region.last
          processor.call(range_start, range_end)
        end
        if focus == true
          @tk.focus
        elsif focus.is_a?(Integer)
          ::Tk.after(focus) { @tk.focus }
        end
      end
      
      def applied_format?(region_start, region_end, option, value)
        !applied_format_tags(region_start, region_end, option, value).empty?
      end
      
      def applied_format_tags(region_start, region_end, option, value)
        tag_names = @tk.tag_names - ['sel', ALL_TAG]
        
        tag_names.select do |tag_name|
          @tk.tag_ranges(tag_name).any? do |range|
            if text_index_less_than_or_equal_to_other_text_index?(range.first, region_start) && text_index_greater_than_or_equal_to_other_text_index?(range.last, region_end)
              @tk.tag_cget(tag_name, option) == value
            end
          end
        end
      end
            
      def applied_format_value(text_index = nil, option)
        text_index ||= @tk.index('insert')
        region_start = text_index
        region_end = text_index
        tag_names = @tk.tag_names - ['sel', ALL_TAG]
        
        values = tag_names.map do |tag_name|
          @tk.tag_ranges(tag_name).map do |range|
            if text_index_less_than_or_equal_to_other_text_index?(range.first, region_start) && text_index_greater_than_or_equal_to_other_text_index?(range.last, region_end)
              @tk.tag_cget(tag_name, option)
            end
          end
        end.flatten.reject {|value| value.to_s.empty?}
        
        values.last || (@tk.send(option) rescue FORMAT_DEFAULT_MAP[option])
      end
            
      def add_format(region_start, region_end, option, value)
        @@tag_number = 0 unless defined?(@@tag_number)
        tag = "tag_#{option}_#{@@tag_number += 1}"
        @tk.tag_configure(tag, {option => value})
        @tk.tag_add(tag, region_start, region_end)
        tag
      end
      
      def remove_format(region_start, region_end, option, value)
        partial_intersection_option_applied_tags = tag_names.select do |tag_name|
          @tk.tag_ranges(tag_name).any? do |range|
            if range.first.to_f.between?(region_start.to_f, region_end.to_f) or
               range.last.to_f.between?(region_start.to_f, region_end.to_f) or
               (text_index_less_than_or_equal_to_other_text_index?(range.first, region_start) && text_index_greater_than_or_equal_to_other_text_index?(range.last, region_end))
              @tk.tag_cget(tag_name, option) == value
            end
          end
        end
        
        partial_intersection_option_applied_tags.each do |tag_name|
          @tk.tag_remove(tag_name, region_start, region_end)
        end
        
        nil
      end
      
      # toggles option/value tag (removes if already applied)
      def toggle_format(region_start, region_end, option, value)
        if applied_format?(region_start, region_end, option, value)
          remove_format(region_start, region_end, option, value)
        else
          add_format(region_start, region_end, option, value)
        end
      end
           
      # TODO Algorithm for font option formatting
      # for a region, grab all the latest tags for each subregion as well as the widget font for subregions without a tag
      # for each part of the region covered by a tag, augment its font with new font option (or remove if that is what is needed)
      # Once add and remove are implemented, implement toggle
      # Also, there is a need for a method that checks if a font option value applies to an entire region (to decide which way to toggle with toggle method)
      def applied_font_format?(region_start, region_end, font_option, value)
        applied_font_format_tags_and_regions(region_start, region_end).all? do |tag, region_start, region_end|
          if tag.nil?
            @tk.font.send(font_option) == value
          else
            @tk.tag_cget(tag, 'font').send(font_option) == value
          end
        end
      end

      def applied_font_format_tags_and_regions(region_start, region_end)
        lines = value.split("\n")
        tags_and_regions = []
        all_tag_names = (@tk.tag_names - ['sel', ALL_TAG]).select {|tag_name| tag_name.include?('_font_')}
        (region_start.to_i..region_end.to_i).each do |line_number|
          start_character_index = 0
          start_character_index = region_start.to_s.split('.').last.to_i if line_number == region_start.to_i
          end_character_index = lines[line_number - 1].to_s.size
          end_character_index = region_end.to_s.split('.').last.to_i if line_number == region_end.to_i
          (start_character_index...end_character_index).each do |character_index|
            text_index = "#{line_number}.#{character_index}"
            region_tag = all_tag_names.reverse.find do |tag|
              @tk.tag_cget(tag, 'font') && @tk.tag_ranges(tag).any? do |range_start, range_end|
                text_index_less_than_or_equal_to_other_text_index?(range_start, text_index) && text_index_greater_than_or_equal_to_other_text_index?(range_end, text_index)
              end
            end
            end_text_index = add_to_text_index(text_index, 1)
            if tags_and_regions&.last && region_tag == tags_and_regions.last.first
              tags_and_regions.last[2] = end_text_index
            else
              tags_and_regions << [region_tag, text_index, end_text_index]
            end
          end
        end
        tags_and_regions
      end
      
      def applied_font_format_value(text_index = nil, font_option)
        text_index ||= @tk.index('insert')
        region_start = text_index
        region_end = @tk.index("#{text_index} + 1 chars")
        tag_names = applied_font_format_tags_and_regions(region_start, region_end).map(&:first)
        
        values = tag_names.map do |tag_name|
          @tk.tag_ranges(tag_name).map do |range|
            if text_index_less_than_or_equal_to_other_text_index?(range.first, region_start) && text_index_greater_than_or_equal_to_other_text_index?(range.last, region_end)
              @tk.tag_cget(tag_name, 'font')
            end
          end
        end.flatten.reject {|value| value.to_s.empty?}
        
        font = values.last
        
        value = font && font.send(font_option)
        
        value || Hash[@tk.font.actual][font_option]
      end

      def add_font_format(region_start, region_end, font_option, value)
        applied_font_format_tags_and_regions(region_start, region_end).each do |tag, tag_region_start, tag_region_end|
          if tag
            bigger_region_tag = @tk.tag_ranges(tag).any? do |range_start, range_end|
              text_index_less_than_other_text_index?(range_start, tag_region_start) || text_index_greater_than_other_text_index?(range_end, tag_region_end)
            end
            if bigger_region_tag
              @tk.tag_ranges(tag).each do |range_start, range_end|
                if text_index_less_than_other_text_index?(range_start, tag_region_start) && text_index_less_than_or_equal_to_other_text_index?(range_end, tag_region_end) && text_index_greater_than_or_equal_to_other_text_index?(range_end, tag_region_start)
                  font = @tk.tag_cget(tag, 'font')
                  remove_format(range_start, range_end, 'font', font)
                  add_format(range_start, tag_region_start, 'font', font)
                  font_clone = clone_font(font)
                  font_clone.send("#{font_option}=", value)
                  add_format(tag_region_start, tag_region_end, 'font', font_clone)
                elsif text_index_greater_than_other_text_index?(range_end, tag_region_end) && text_index_greater_than_or_equal_to_other_text_index?(range_start, tag_region_start) && text_index_less_than_or_equal_to_other_text_index?(range_start, tag_region_end)
                  font = @tk.tag_cget(tag, 'font')
                  remove_format(range_start, range_end, 'font', font)
                  add_format(tag_region_end, range_end, 'font', font)
                  font_clone = clone_font(font)
                  font_clone.send("#{font_option}=", value)
                  add_format(tag_region_start, tag_region_end, 'font', font_clone)
                elsif text_index_less_than_other_text_index?(range_start, tag_region_start) && text_index_greater_than_other_text_index?(range_end, tag_region_end)
                  font = @tk.tag_cget(tag, 'font')
                  remove_format(range_start, range_end, 'font', font)
                  add_format(range_start, tag_region_start, 'font', font)
                  remove_format(range_start, range_end, 'font', font)
                  add_format(tag_region_end, range_end, 'font', font)
                  font_clone = clone_font(font)
                  font_clone.send("#{font_option}=", value)
                  add_format(tag_region_start, tag_region_end, 'font', font_clone)
                end
              end
            else
              current_font = @tk.tag_cget(tag, 'font')
              current_font.send("#{font_option}=", value)
            end
          else
            add_format(tag_region_start, tag_region_end, 'font', default_font_attributes.merge(font_option => value))
          end
        end
      end

      def remove_font_format(region_start, region_end, font_option, value)
        applied_font_format_tags_and_regions(region_start, region_end).each do |tag, tag_region_start, tag_region_end|
          if tag
            bigger_region_tag = @tk.tag_ranges(tag).any? do |range_start, range_end|
              text_index_less_than_other_text_index?(range_start, tag_region_start) || text_index_greater_than_other_text_index?(range_end, tag_region_end)
            end
            if bigger_region_tag
              @tk.tag_ranges(tag).each do |range_start, range_end|
                if text_index_less_than_other_text_index?(range_start, tag_region_start) && text_index_less_than_or_equal_to_other_text_index?(range_end, tag_region_end) && text_index_greater_than_or_equal_to_other_text_index?(range_end, tag_region_start)
                  font = @tk.tag_cget(tag, 'font')
                  remove_format(range_start, range_end, 'font', font)
                  add_format(range_start, subtract_from_text_index(tag_region_start, 1), 'font', font)
                  font_clone = clone_font(font)
                  font_clone.send("#{font_option}=", default_for_font_option(font_option))
                  add_format(tag_region_start, tag_region_end, 'font', font_clone)
                elsif text_index_greater_than_other_text_index?(range_end, tag_region_end) && text_index_greater_than_or_equal_to_other_text_index?(range_start, tag_region_start) && text_index_less_than_or_equal_to_other_text_index?(range_start, tag_region_end)
                  font = @tk.tag_cget(tag, 'font')
                  remove_format(range_start, range_end, 'font', font)
                  add_format(add_to_text_index(tag_region_end, 1), range_end, 'font', font)
                  font_clone = clone_font(font)
                  font_clone.send("#{font_option}=", default_for_font_option(font_option))
                  add_format(tag_region_start, tag_region_end, 'font', font_clone)
                elsif text_index_less_than_other_text_index?(range_start, tag_region_start) && text_index_greater_than_other_text_index?(range_end, tag_region_end)
                  font = @tk.tag_cget(tag, 'font')
                  remove_format(range_start, range_end, 'font', font)
                  add_format(range_start, subtract_from_text_index(tag_region_start, 1), 'font', font)
                  remove_format(range_start, range_end, 'font', font)
                  add_format(add_to_text_index(tag_region_end, 1), range_end, 'font', font)
                  font_clone = clone_font(font)
                  font_clone.send("#{font_option}=", default_for_font_option(font_option))
                  add_format(tag_region_start, tag_region_end, 'font', font_clone)
                end
              end
            else
              current_font = @tk.tag_cget(tag, 'font')
              current_font.send("#{font_option}=", default_for_font_option(font_option))
            end
          else
            add_format(tag_region_start, tag_region_end, 'font', default_font_attributes.merge(font_option => default_for_font_option(font_option)))
          end
        end
      end

      # toggles option/value tag (removes if already applied)
      def toggle_font_format(region_start, region_end, option, value)
        if applied_font_format?(region_start, region_end, option, value)
          remove_font_format(region_start, region_end, option, value)
        else
          add_font_format(region_start, region_end, option, value)
        end
      end
      
      def default_for_font_option(font_option)
        @tk.font.send(font_option)
      end
      
      def default_font_attributes
        Hash[@tk.font.actual]
      end
      
      def add_to_text_index(text_index, addition)
        text_index_parts = text_index.split('.')
        line = text_index_parts.first
        char_index = text_index_parts.last
        char_index = char_index.to_i + addition
        "#{line}.#{char_index}"
      end
            
      def subtract_from_text_index(text_index, subtraction)
        add_to_text_index(text_index, -1 * subtraction)
      end
      
      def text_index_less_than_other_text_index?(region1, region2)
        region1_parts = region1.to_s.split('.')
        region2_parts = region2.to_s.split('.')
        return true if region1_parts.first.to_i < region2_parts.first.to_i
        return false if region1_parts.first.to_i > region2_parts.first.to_i
        region1_parts.last.to_i < region2_parts.last.to_i
      end
            
      def text_index_less_than_or_equal_to_other_text_index?(region1, region2)
        region1_parts = region1.to_s.split('.')
        region2_parts = region2.to_s.split('.')
        return true if region1_parts.first.to_i < region2_parts.first.to_i
        return false if region1_parts.first.to_i > region2_parts.first.to_i
        region1_parts.last.to_i <= region2_parts.last.to_i
      end
            
      def text_index_greater_than_other_text_index?(region1, region2)
        region1_parts = region1.to_s.split('.')
        region2_parts = region2.to_s.split('.')
        return true if region1_parts.first.to_i > region2_parts.first.to_i
        return false if region1_parts.first.to_i < region2_parts.first.to_i
        region1_parts.last.to_i > region2_parts.last.to_i
      end
            
      def text_index_greater_than_or_equal_to_other_text_index?(region1, region2)
        region1_parts = region1.to_s.split('.')
        region2_parts = region2.to_s.split('.')
        return true if region1_parts.first.to_i > region2_parts.first.to_i
        return false if region1_parts.first.to_i < region2_parts.first.to_i
        region1_parts.last.to_i >= region2_parts.last.to_i
      end
      
      def insert_image(text_index, *image_args)
        TkTextImage.new(@tk, 'insert', :image => image_argument(image_args))
      end
      
      def get_open_file_to_insert_image(text_index = 'insert')
        image_filename = Glimmer::DSL::Tk::BuiltInDialogExpression.new.interpret(nil, 'get_open_file', filetypes: {
          'PNG Images' => '.png',
          'Gif Images' => '.gif',
          'PPM Images' => '.ppm'
        })
        insert_image('insert', image_filename) unless image_filename.nil? || image_filename.to_s.empty?
      end
      
      private
      
      def initialize_defaults
        super
        self.font = {family: 'Courier New'}
        self.wrap = 'none'
        self.padx = 5
        self.pady = 5
      end
      
      def clone_font(font)
        ::TkFont.new(Hash[font.actual])
      end
    end
  end
end