AndyObtiva/glimmer-dsl-swt

View on GitHub
lib/glimmer/swt/custom/code_text.rb

Summary

Maintainability
D
2 days
Test Coverage
F
25%
# Copyright (c) 2007-2024 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/ui/custom_widget'

module Glimmer
  module SWT
    module Custom
      # CodeText is a customization of StyledText with support for Ruby Syntax Highlighting
      class CodeText
        include Glimmer::UI::CustomWidget
        
        class << self
          def languages
            require 'rouge'
            Rouge::Lexer.all.map {|lexer| lexer.tag}.sort
          end
          
          def lexers
            require 'rouge'
            Rouge::Lexer.all.sort_by(&:title)
          end
        end
        
        REGEX_COLOR_HEX6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/
        FONT_NAMES_PREFERRED = ['Consolas', 'Courier', 'Monospace', 'Liberation Mono']
        SHORTCUT_KEY_COMMAND = OS.mac? ? :command : :ctrl
        
        # TODO support auto language detection
              
        option :language, default: 'ruby'
        # TODO consider supporting data-binding of language
        # TODO support switcher of language that automatically updates the lexer
        # TODO support method for redrawing the syntax highlighting
        option :theme, default: 'glimmer'
        option :lines, default: false

        # This option indicates if default keyboard handlers/menus should be activated
        option :default_behavior, default: true
      
        alias lines? lines
        attr_accessor :styled_text_proxy_text, :styled_text_proxy_top_pixel
        attr_reader :styled_text_proxy, :lines_width, :line_numbers_styled_text_proxy
        
        def method_missing(method_name, *args, &block)
          dsl_mode = @dsl_mode || args.last.is_a?(Hash) && args.last[:dsl]
          if dsl_mode
            args.pop if args.last.is_a?(Hash) && args.last[:dsl]
            super(method_name, *args, &block)
          elsif @styled_text_proxy&.respond_to?(method_name, *args, &block)
            @line_numbers_styled_text_proxy&.send(method_name, *args, &block) if method_name.to_s == 'font='
            @styled_text_proxy&.send(method_name, *args, &block)
          else
            super
          end
        end
        
        def respond_to?(method_name, *args, &block)
          dsl_mode = @dsl_mode || args.last.is_a?(Hash) && args.last[:dsl]
          if dsl_mode
            args = args[0...-1] if args.last.is_a?(Hash) && args.last[:dsl]
            super(method_name, *args, &block)
          else
            super || @styled_text_proxy&.respond_to?(method_name, *args, &block)
          end
        end
        
        def has_instance_method?(method_name)
          respond_to?(method_name)
        end
        
        def can_add_observer?(attribute_name)
          @styled_text_proxy&.can_add_observer?(attribute_name) || super
        end
  
        def add_observer(observer, attribute_name)
          if @styled_text_proxy&.can_add_observer?(attribute_name)
            @styled_text_proxy.add_observer(observer, attribute_name)
          else
            super
          end
        end
                
        def can_handle_observation_request?(observation_request)
          @styled_text_proxy&.can_handle_observation_request?(observation_request) || super
        rescue
          super
        end

        def handle_observation_request(observation_request, &block)
          if @styled_text_proxy&.can_handle_observation_request?(observation_request)
            @styled_text_proxy.handle_observation_request(observation_request, &block)
          else
            super
          end
        rescue
          super
        end
        
        def root_block=(block)
          body_root.content(&block)
        end
        
        def line_numbers_block=(block)
          @line_numbers_styled_text_proxy.content(&block)
        end
        
        def code_block=(block)
          @styled_text_proxy.content(&block)
        end
        
        before_body do
          require 'rouge'
          require 'ext/rouge/themes/glimmer'
          require 'ext/rouge/themes/glimmer_dark'
          @dark_mode = Java::OrgEclipseSwtWidgets::Display.system_dark_theme?
          self.theme = 'glimmer_dark' if @dark_mode && !theme.downcase.include?('dark')
          @dark_theme = theme.include?('dark')
          @swt_style = swt_style == 0 ? [:border, :multi, :v_scroll, :h_scroll] : swt_style
          select_best_font
          if lines == true
            @lines_width = 4
          elsif lines.is_a?(Hash)
            @lines_width = lines[:width]
          end
          @dsl_mode = true
        end
        
        after_body do
          @dsl_mode = nil
        end
        
        body {
          if lines
            @composite = composite {
              grid_layout(2, false)
              
              @line_numbers_styled_text_proxy = styled_text(swt(swt(@swt_style), :h_scroll!, :v_scroll!)) {
                layout_data(:right, :fill, false, true)

                text <= [self, :styled_text_proxy_text,
                          on_read: lambda { |text_value| line_numbers_text_from(text_value) },
                          after_read: lambda { @line_numbers_styled_text_proxy&.top_pixel = styled_text_proxy_top_pixel unless styled_text_proxy_top_pixel.nil? }
                        ]
                top_pixel <= [self, :styled_text_proxy_top_pixel]
                font @font_options
                background color(:widget_background)
                foreground @dark_mode ? rgb(255, 255, 127) : :dark_blue
                top_margin 5
                right_margin 5
                bottom_margin 5
                left_margin 5
                editable false
                caret nil
                
                on_focus_gained {
                  @styled_text_proxy&.setFocus
                }
                on_key_pressed {
                  @styled_text_proxy&.setFocus
                }
                on_mouse_up {
                  @styled_text_proxy&.setFocus
                }
              }

              code_text_widget
            }
          else
            code_text_widget
          end
        }
        
        def code_text_widget
          @styled_text_proxy = styled_text(@swt_style) {
#             custom_widget_property_owner # TODO implement to route properties here without declaring method_missing
            layout_data :fill, :fill, true, true if lines
            
            text <=> [self, :styled_text_proxy_text] if lines
            top_pixel <=> [self, :styled_text_proxy_top_pixel] if lines
            font @font_options
            background :black if @dark_mode
            foreground rgb(75, 75, 75)
            left_margin 5
            top_margin 5
            right_margin 5
            bottom_margin 5
            tabs 2
            
            if default_behavior
              on_key_pressed { |event|
                character = event.keyCode.chr rescue nil
                case [event.stateMask, character]
                when [swt(SHORTCUT_KEY_COMMAND), 'a']
                  @styled_text_proxy.selectAll
                when [(swt(:ctrl) if OS.mac?), 'a']
                  jump_to_beginning_of_line
                when [(swt(:ctrl) if OS.mac?), 'e']
                  jump_to_end_of_line
                when [swt(SHORTCUT_KEY_COMMAND), '=']
                  bump_font_height_up
                when [swt(SHORTCUT_KEY_COMMAND), '-']
                  bump_font_height_down
                when [swt(SHORTCUT_KEY_COMMAND), '0']
                  restore_font_height
                end
              }
              on_verify_text { |verify_event|
                if verify_event.text == "\n"
                  line_index = verify_event.widget.get_line_at_offset(verify_event.widget.get_caret_offset)
                  line = verify_event.widget.get_line(line_index)
                  line_indent = line.match(/^([ ]*)/)[1].to_s.size
                  verify_event.text += ' '*line_indent
                  verify_event.text += ' '*2 if line.strip.end_with?('{') || line.strip.match(/do([ ]*[|][^|]*[|])?$/) || line.start_with?('class') || line.start_with?('module') || line.strip.start_with?('def')
                end
              }
            end
            
            on_modify_text do |event|
              # clear unnecessary syntax highlighting cache on text updates, and do it async to avoid affecting performance
              new_text = event.data
              async_exec do
                unless @syntax_highlighting.nil?
                  lines = new_text.to_s.split("\n")
                  line_diff = @syntax_highlighting.keys - lines
                  line_diff.each do |line|
                    @syntax_highlighting.delete(line)
                  end
                end
              end
            end
                  
            on_line_get_style do |line_style_event|
              begin
                styles = []
                style_data = nil
                syntax_highlighting(line_style_event.lineText).to_a.each do |token_hash|
                  start_index = token_hash[:token_index]
                  size = token_hash[:token_text].size
                  style_data = Rouge::Theme.find(theme).new.style_for(token_hash[:token_type])
                  foreground_color = hex_color_to_swt_color(style_data[:fg], [@dark_mode ? :white : :black])
                  background_color = hex_color_to_swt_color(style_data[:bg], [@dark_mode ? :black : :white])
                  font_styles = []
                  font_styles << :bold if style_data[:bold]
                  font_styles << :italic if style_data[:italic]
                  font_style = SWTProxy[*font_styles]
                  styles << StyleRange.new(line_style_event.lineOffset + start_index, size, foreground_color, background_color, font_style)
                end
                line_style_event.styles = styles.to_java(StyleRange) unless styles.empty?
              rescue => e
                Glimmer::Config.logger.error {"Error encountered with style data: #{style_data}"}
                Glimmer::Config.logger.error {e.message}
                Glimmer::Config.logger.error {e.full_message}
              end
            end
          }
        end
        
        def syntax_highlighting(text)
          return [] if text.to_s.strip.empty?
          @syntax_highlighting ||= {}
          unless @syntax_highlighting.keys.include?(text)
            lex = lexer.lex(text).to_a
            text_size = 0
            @syntax_highlighting[text] = lex.map do |pair|
              {token_type: pair.first, token_text: pair.last}
            end.each do |hash|
              hash[:token_index] = text_size
              text_size += hash[:token_text].size
            end
          end
          @syntax_highlighting[text]
        end
        
        def lexer
          require 'rouge'
          # TODO Try to use Rouge::Lexer.find_fancy('guess', code) in the future to guess the language or otherwise detect it from file extension
          @lexer ||= Rouge::Lexer.find_fancy(language)
          @lexer ||= Rouge::Lexer.find_fancy('ruby') # default to Ruby if no lexer is found
        end
        
        # TODO extract this to ColorProxy
        def hex_color_to_swt_color(color_data, default_color)
          color_data = "##{color_data.chars.drop(1).map {|c| c*2}.join}" if color_data.is_a?(String) && color_data.start_with?('#') && color_data&.size == 4
          color_data = color_data.match(REGEX_COLOR_HEX6).to_a.drop(1).map {|c| "0x#{c}".hex}.to_a if color_data.is_a?(String) && color_data.start_with?('#')
          color_data = [color_data] unless color_data.nil? || color_data.empty? || color_data.is_a?(Array)
          color_data = default_color if color_data.nil? || color_data.empty?
          color(*color_data).swt_color
        end
        
        def jump_to_beginning_of_line
          current_line_index = @styled_text_proxy.getLineAtOffset(@styled_text_proxy.getCaretOffset)
          beginning_of_current_line_offset = @styled_text_proxy.getOffsetAtLine(current_line_index)
          @styled_text_proxy.setSelection(beginning_of_current_line_offset, beginning_of_current_line_offset)
        end
        
        def jump_to_end_of_line
          current_line_index = @styled_text_proxy.getLineAtOffset(@styled_text_proxy.getCaretOffset)
          current_line = @styled_text_proxy.getLine(current_line_index)
          beginning_of_current_line_offset = @styled_text_proxy.getOffsetAtLine(current_line_index)
          new_offset = beginning_of_current_line_offset + current_line.size
          @styled_text_proxy.setSelection(new_offset, new_offset)
        end
        
        def line_numbers_text_from(text_value)
          line_count = "#{text_value} ".split("\n").count
          line_count = 1 if line_count == 0
          lines_text_size = [line_count.to_s.size, @lines_width].max
          @lines_width = lines_text_size if lines_text_size > @lines_width
          line_count.times.map {|n| (' ' * (lines_text_size - (n+1).to_s.size)) + (n+1).to_s }.join("\n") + "\n"
        end
        
        def select_best_font
          select_best_font_name
          @font_options = {height: OS.mac? ? 15 : 12}
          @font_options.merge!(name: @font_name) if @font_name
          @font_options
        end
        
        def select_best_font_name
          all_font_names = display.get_font_list(nil, true).map(&:name)
          @font_name = 'Courier' if OS.mac?
          FONT_NAMES_PREFERRED.each do |font_name|
            @font_name ||= font_name if all_font_names.include?(font_name)
          end
          @font_name ||= all_font_names.find {|font_name| font_name.downcase.include?('mono')}
          @font_name
        end
        
        def bump_font_height_up
          @original_font_height ||= font_datum.height
          new_font_height = font_datum.height + 1
          update_font_height(new_font_height)
        end
        
        def bump_font_height_down
          @original_font_height ||= font_datum.height
          new_font_height = (font_datum.height - 1) == 0 ? font_datum.height : (font_datum.height - 1)
          update_font_height(new_font_height)
        end
        
        def restore_font_height
          return if @original_font_height.nil?
          update_font_height(@original_font_height)
          @original_font_height = nil
        end
        
        def update_font_height(new_font_height)
          return if new_font_height.nil?
          @styled_text_proxy.font = {name: font_datum.name, height: new_font_height, style: font_datum.style}
          @line_numbers_styled_text_proxy&.font = {name: font_datum.name, height: new_font_height, style: font_datum.style}
          @body_root.shell_proxy.layout(true, true)
          @body_root.shell_proxy.pack_same_size
        end
        
        def font_datum
          @styled_text_proxy.font.font_data.first
        end
      end
    end
  end
end