# Copyright (c) 2021-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 'os' require 'glimmer/libui/custom_control' require 'glimmer/libui/syntax_highlighter' module Glimmer module LibUI module CustomControl class CodeEntry include Glimmer::LibUI::CustomControl REGEX_COLOR_HEX6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/ # TODO vary shortcut key by OS (CMD for Mac, CTRL elsewhere) option :language, default: 'ruby' option :theme, default: 'glimmer' option :code option :padding, default: 10 option :caret_blinking_delay_in_seconds, default: 0.5 option :font_family, default: OS.mac? ? 'Courier New' : 'Courier' option :font_size, default: 14 # TODO consider offering the option to autosave to a file upon changes attr_reader :syntax_highlighter, :line, :position before_body do @syntax_highlighter = SyntaxHighlighter.new(language: language, theme: theme) @font_default = {family: font_family, size: font_size, weight: :medium, italic: :normal, stretch: :normal} @font_italic = @font_default.merge(italic: :italic) @line = 0 @position = 5 @draw_caret = false @multiplier_position = 0.6 @multiplier_line = 1.2 end after_body do LibUI.timer(caret_blinking_delay_in_seconds/2.0) do body_root.redraw end end body { scrolling_area(1, 1) { |code_entry_area| on_draw do # TODO need to determine the scrolling area width and height from the text extent once supported in the future # TODO only reset size when a new line has been added area_width = longest_line_size * font_size*@multiplier_position area_height = line_count * font_size*@multiplier_line code_entry_area.set_size(area_width, area_height) rectangle(0, 0, area_width, area_height) { fill :white } code_layer caret_layer if @draw_caret if @blinking_time.nil? || (Time.now - @blinking_time > caret_blinking_delay_in_seconds) @blinking_time = Time.now @draw_caret = !@draw_caret end end on_mouse_down do |mouse_event| # once text extent calculation via libui is supported, consider the idea of splitting # text by single characters to use every character extent in determining mouse location # or not splitting but using the extent of one character to determine mouse location @position = (mouse_event[:x] - padding) / (font_size*@multiplier_position) @line = (mouse_event[:y] - padding) / (font_size*@multiplier_line) @line = [@line, code.lines.length - 1].min @position = [@position, current_code_line_max_position].min body_root.redraw end # TODO mouse click based text selection # TODO keyboar based text selection on_key_down do |key_event| # TODO consider delegating some of the logic below to a model handled = true # assume it is handled for all cases except the else clause below case key_event in modifiers: [], ext_key: :left if @position == 0 if @line > 0 new_position = code.lines[line - 1].length - 1 @line = [@line - 1, 0].max @position = new_position end else @position = [@position - 1, 0].max end in modifiers: [], ext_key: :right if @position == current_code_line_max_position if @line < code.lines.size - 1 @line += 1 @position = 0 end else @position += 1 end in modifiers: [], ext_key: :up # TODO scroll view when going down or up or paging or going home / end @line = [@line - 1, 0].max if @max_position @position = @max_position @max_position = nil end in modifiers: [], ext_key: :down @line += 1 if @max_position @position = @max_position @max_position = nil end in modifiers: [], ext_key: :page_up @line = [@line - 15, 0].max if @max_position @position = @max_position @max_position = nil end in modifiers: [], ext_key: :page_down @line += 15 if @max_position @position = @max_position @max_position = nil end in modifiers: [], ext_key: :home @line = 0 @position = 0 in modifiers: [], ext_key: :end @line = code.lines.size - 1 @position = current_code_line_max_position in ext_key: :delete code.slice!(caret_index) in key: "\n" code.insert(caret_index, "\n") @line += 1 @position = 0 # TODO indent upon hitting enter in key: "\b" if @position == 0 if @line > 0 new_position = code.lines[line - 1].length - 1 code.slice!(caret_index - 1) @line = [@line - 1, 0].max @position = new_position end else @position = [@position - 1, 0].max code.slice!(caret_index) end in key: "\t" code.insert(caret_index, ' ') @position += 2 in modifiers: [:control], key: 'a' @position = 0 in modifiers: [:command], ext_key: :left @position = 0 in modifiers: [:control], key: 'e' @position = current_code_line_max_position in modifiers: [:command], ext_key: :right @position = current_code_line_max_position in modifiers: [:shift], key_code: 48 code.insert(caret_index, ')') @position += 1 in modifiers: [:alt], ext_key: :right if @position == current_code_line_max_position if @line < code.lines.size - 1 @line += 1 @position = 0 end else new_caret_index = caret_index new_caret_index += 1 while code[new_caret_index + 1]&.match(/[^a-zA-Z]/) new_caret_index += 1 until code[new_caret_index + 1].nil? || code[new_caret_index + 1].match(/[^a-zA-Z]/) @position += new_caret_index + 1 - caret_index end in modifiers: [:alt], ext_key: :left if @position == 0 if @line > 0 new_position = code.lines[line - 1].length - 1 @line = [@line - 1, 0].max @position = new_position end else new_caret_index = caret_index new_caret_index -= 1 while code[new_caret_index - 1]&.match(/[^a-zA-Z]/) new_caret_index -= 1 until code[new_caret_index + 1].nil? || code[new_caret_index - 1].match(/[^a-zA-Z]/) @position -= caret_index - new_caret_index @position = [@position, 0].max end in modifier: nil, modifiers: [] code.insert(caret_index, key_event[:key]) @position += 1 in modifier: nil, modifiers: [:shift] character = key_event[:key] || key_event[:key_code].chr.capitalize code.insert(caret_index, character) @position += 1 # TODO CMD Z (undo) # TODO CMD SHIFT Z (redo) # TODO CMD + [ (outdent) # TODO CMD + ] (indent) # TODO CMD + down (move line down) # TODO CMD + up (move line up) # TODO CMD + D (duplicate) else handled = false end @line = [@line, code.lines.length - 1].min @line = [@line, 0].max new_position = [@position, current_code_line_max_position].min if new_position != @position @max_position = @position @position = new_position end @draw_caret = true body_root.redraw handled end } } def code_layer text(padding, padding) { default_font @font_default syntax_highlighter.syntax_highlighting(code).each do |token| token_text = token[:token_text].start_with?("\n") ? " #{token[:token_text]}" : token[:token_text] string(token_text) { font @font_italic if token[:token_style][:italic] color token[:token_style][:fg] || :black background token[:token_style][:bg] || :white } end } end def caret_layer # TODO adjust padding offset based on text extent text(padding - 4, padding) { default_font @font_default # TODO make caret blink string(caret_text) { color :black background [0, 0, 0, 0] } } end def caret_text # TODO replace | with a real caret (see if there is an emoji or special character for it) ("\n" * @line) + (' ' * @position) + '|' end def caret_index code.lines[0..line].join.length - (current_code_line.length - @position) end def current_code_line code.lines[@line] end def current_code_line_max_position (current_code_line && current_code_line.length > 0) ? (current_code_line.length - 1) : 0 end def longest_line_size code&.lines&.map(&:size)&.max || 1 end def line_count code&.lines&.size || 1 end end end end end