grmenu 3.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,12 +6,15 @@ require 'zlib'
6
6
  require 'open3'
7
7
 
8
8
  class GRmenu
9
+ VERSION = "4.0.2"
9
10
  CLEAR_SCREEN_SEQUENCE = "\e[H\e[2J\e[3J"
10
11
  HIDE_CURSOR = "\e[?25l"
11
12
  SHOW_CURSOR = "\e[?25h"
12
13
  CURSOR_HOME = "\e[H"
13
14
  CLEAR_TO_EOL = "\e[K"
14
15
  CLEAR_TO_EOS = "\e[J"
16
+ ENABLE_MOUSE = "\e[?1000h\e[?1002h\e[?1006h"
17
+ DISABLE_MOUSE = "\e[?1006l\e[?1002l\e[?1000l"
15
18
 
16
19
  def self.find_data_file(filename)
17
20
  local_path = File.expand_path("data/#{filename}", __dir__)
@@ -21,6 +24,30 @@ class GRmenu
21
24
  nil
22
25
  end
23
26
 
27
+ def self.enable_windows_vt
28
+ return unless Gem.win_platform? || RUBY_PLATFORM =~ /mswin|mingw|cygwin/
29
+ require 'fiddle'
30
+ kernel32 = Fiddle.dlopen('kernel32')
31
+ get_std_handle = Fiddle::Function.new(kernel32['GetStdHandle'], [Fiddle::TYPE_INT], Fiddle::TYPE_VOIDP)
32
+ get_console_mode = Fiddle::Function.new(kernel32['GetConsoleMode'], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT)
33
+ set_console_mode = Fiddle::Function.new(kernel32['SetConsoleMode'], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
34
+
35
+ h_out = get_std_handle.call(-11)
36
+ out_mode = ' ' * 4
37
+ if get_console_mode.call(h_out, out_mode) != 0
38
+ m = out_mode.unpack1('L')
39
+ set_console_mode.call(h_out, m | 0x0004)
40
+ end
41
+
42
+ h_in = get_std_handle.call(-10)
43
+ in_mode = ' ' * 4
44
+ if get_console_mode.call(h_in, in_mode) != 0
45
+ m = in_mode.unpack1('L')
46
+ set_console_mode.call(h_in, m | 0x0200)
47
+ end
48
+ rescue StandardError
49
+ end
50
+
24
51
  def self.load_json_data(filename)
25
52
  path = find_data_file(filename)
26
53
  return {} unless path && File.exist?(path)
@@ -30,9 +57,37 @@ class GRmenu
30
57
  end
31
58
 
32
59
  COLORS = load_json_data('colors.json').freeze
33
- BORDERS = load_json_data('borders.json').transform_keys(&:to_i).transform_values { |v| v.transform_keys(&:to_sym) }.freeze
60
+ BORDERS = load_json_data('borders.json').transform_keys(&:to_i).transform_values { |v| v.is_a?(Hash) ? v.transform_keys(&:to_sym) : { h: v.to_s, v: v.to_s, tl: v.to_s, tr: v.to_s, bl: v.to_s, br: v.to_s } }.freeze
34
61
  FONTS = load_json_data('fonts.json').transform_keys(&:to_i).freeze
35
62
 
63
+ BASE_RGB = COLORS.each_with_object({}) do |(name, val), h|
64
+ next if name == "reset"
65
+ code = val.is_a?(Hash) ? (val["2"] || val[2] || val["1"] || val[1]) : val.to_s
66
+ if code =~ /38;2;(\d+);(\d+);(\d+)/
67
+ h[name] = [$1.to_i, $2.to_i, $3.to_i]
68
+ elsif code == "90m" || code == "30m"
69
+ h[name] = [100, 100, 100]
70
+ elsif code == "91m" || code == "31m"
71
+ h[name] = [255, 60, 60]
72
+ elsif code == "92m" || code == "32m"
73
+ h[name] = [60, 255, 60]
74
+ elsif code == "93m" || code == "33m"
75
+ h[name] = [255, 255, 60]
76
+ elsif code == "94m" || code == "34m"
77
+ h[name] = [60, 120, 255]
78
+ elsif code == "95m" || code == "35m"
79
+ h[name] = [255, 60, 255]
80
+ elsif code == "96m" || code == "36m"
81
+ h[name] = [60, 255, 255]
82
+ elsif code == "97m" || code == "37m"
83
+ h[name] = [250, 250, 250]
84
+ elsif code =~ /38;5;(\d+)/
85
+ h[name] = [150, 150, 150]
86
+ else
87
+ h[name] = [220, 220, 220]
88
+ end
89
+ end.freeze
90
+
36
91
  FONT_1 = FONTS[1] || {}
37
92
  FONT_2 = FONTS[2] || {}
38
93
  FONT_3 = FONTS[3] || {}
@@ -53,8 +108,26 @@ class GRmenu
53
108
  end
54
109
 
55
110
  def self.ansi_color(color_name, level = 1)
56
- name = color_name.to_s.downcase
111
+ name = color_name.to_s.downcase.strip
112
+ if name.include?(":")
113
+ parts = name.split(":")
114
+ name = parts[0].strip
115
+ level = parts[1].to_i if parts[1] && !parts[1].empty?
116
+ end
57
117
  return rgb_color(0.0) if name == "rgb" || name == "rainbow" || name == "chroma"
118
+ if name =~ /\A#?([0-9a-f]{6})\z/i
119
+ hex = $1
120
+ r = hex[0..1].to_i(16)
121
+ g = hex[2..3].to_i(16)
122
+ b = hex[4..5].to_i(16)
123
+ return "\e[38;2;#{r};#{g};#{b}m"
124
+ elsif name =~ /\A#?([0-9a-f]{3})\z/i
125
+ hex = $1
126
+ r = (hex[0] * 2).to_i(16)
127
+ g = (hex[1] * 2).to_i(16)
128
+ b = (hex[2] * 2).to_i(16)
129
+ return "\e[38;2;#{r};#{g};#{b}m"
130
+ end
58
131
  lvl_str = level.to_s
59
132
  code_raw = COLORS.dig(name, lvl_str) || COLORS.dig(name, level.to_i) || COLORS[name]
60
133
  return "\e[#{code_raw}" if code_raw
@@ -68,24 +141,6 @@ class GRmenu
68
141
  module Color
69
142
  RESET = "\e[0m"
70
143
  BOLD = "\e[1m"
71
-
72
- CODES = {
73
- black: { 1 => "\e[30m", 2 => "\e[90m" },
74
- gray: { 1 => "\e[90m", 2 => "\e[38;5;245m" },
75
- grey: { 1 => "\e[90m", 2 => "\e[38;5;245m" },
76
- red: { 1 => "\e[31m", 2 => "\e[91m" },
77
- green: { 1 => "\e[32m", 2 => "\e[92m" },
78
- yellow: { 1 => "\e[33m", 2 => "\e[93m" },
79
- blue: { 1 => "\e[34m", 2 => "\e[94m" },
80
- magenta: { 1 => "\e[35m", 2 => "\e[95m" },
81
- purple: { 1 => "\e[38;5;129m", 2 => "\e[38;5;141m" },
82
- pink: { 1 => "\e[38;5;205m", 2 => "\e[38;5;218m" },
83
- cyan: { 1 => "\e[36m", 2 => "\e[96m" },
84
- aqua: { 1 => "\e[38;5;45m", 2 => "\e[38;5;51m" },
85
- orange: { 1 => "\e[38;5;208m", 2 => "\e[38;5;214m" },
86
- white: { 1 => "\e[37m", 2 => "\e[97m" }
87
- }.freeze
88
-
89
144
  module_function
90
145
 
91
146
  def paint(text, color_name, level = 1)
@@ -93,7 +148,7 @@ class GRmenu
93
148
  if c_str == "rgb" || c_str == "rainbow" || c_str == "chroma"
94
149
  return rgb(text)
95
150
  end
96
- code = CODES.dig(color_name.to_sym, level) || "\e[37m"
151
+ code = GRmenu.ansi_color(color_name, level) || "\e[37m"
97
152
  "#{code}#{text}#{RESET}"
98
153
  end
99
154
 
@@ -174,6 +229,19 @@ class GRmenu
174
229
  def bright_gray(s); paint(s, :gray, 2); end
175
230
  def grey(s); gray(s); end
176
231
 
232
+ def neon_red(s); paint(s, :neon_red, 2); end
233
+ def neon_green(s); paint(s, :neon_green, 2); end
234
+ def neon_cyan(s); paint(s, :neon_cyan, 2); end
235
+ def neon_blue(s); paint(s, :neon_blue, 2); end
236
+ def neon_pink(s); paint(s, :neon_pink, 2); end
237
+ def neon_yellow(s); paint(s, :neon_yellow, 2); end
238
+ def neon_orange(s); paint(s, :neon_orange, 2); end
239
+ def neon_purple(s); paint(s, :neon_purple, 2); end
240
+ def neon_magenta(s); paint(s, :neon_magenta, 2); end
241
+ def neon_aqua(s); paint(s, :neon_aqua, 2); end
242
+ def neon_lime(s); paint(s, :neon_lime, 2); end
243
+ def neon_white(s); paint(s, :neon_white, 2); end
244
+
177
245
  def r(s); bright_red(s); end
178
246
  def dr(s); dark_red(s); end
179
247
  def g(s); bright_green(s); end
@@ -183,6 +251,26 @@ class GRmenu
183
251
  def cy(s); bright_cyan(s); end
184
252
  def mg(s); bright_magenta(s); end
185
253
  def bl(s); bright_blue(s); end
254
+
255
+ def hex(code, text)
256
+ c = GRmenu.ansi_color(code.to_s)
257
+ "#{c}#{text}#{RESET}"
258
+ end
259
+
260
+ def respond_to_missing?(method_name, include_private = false)
261
+ GRmenu::COLORS.key?(method_name.to_s) || super
262
+ end
263
+
264
+ def method_missing(method_name, *args, &block)
265
+ m_str = method_name.to_s
266
+ if GRmenu::COLORS.key?(m_str)
267
+ text = args[0].to_s
268
+ lvl = args[1] || 2
269
+ paint(text, m_str, lvl)
270
+ else
271
+ super
272
+ end
273
+ end
186
274
  end
187
275
  C = Color
188
276
 
@@ -596,6 +684,9 @@ class GRmenu
596
684
  lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", tick)
597
685
  if @title && !@title.empty?
598
686
  t_str = @title.to_s
687
+ if GRmenu.display_width(t_str) > inner_w
688
+ t_str = t_str[0...[inner_w - 3, 1].max] + "..."
689
+ end
599
690
  pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
600
691
  l_p = " " * (pad_t / 2)
601
692
  r_p = " " * (pad_t - (pad_t / 2))
@@ -612,6 +703,9 @@ class GRmenu
612
703
  lines << "#{Color.rgb(v_l, tick)} #{bar_line} #{Color.rgb(v_r, tick)}"
613
704
  if @status && !@status.empty?
614
705
  st_str = @status.to_s
706
+ if GRmenu.display_width(st_str) > inner_w
707
+ st_str = st_str[0...[inner_w - 3, 1].max] + "..."
708
+ end
615
709
  pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
616
710
  st_line = st_str + (" " * pad_st)
617
711
  lines << "#{Color.rgb(v_l, tick)} #{Color.gray(st_line)} #{Color.rgb(v_r, tick)}"
@@ -628,6 +722,9 @@ class GRmenu
628
722
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
629
723
  if @title && !@title.empty?
630
724
  t_str = @title.to_s
725
+ if GRmenu.display_width(t_str) > inner_w
726
+ t_str = t_str[0...[inner_w - 3, 1].max] + "..."
727
+ end
631
728
  pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
632
729
  l_p = " " * (pad_t / 2)
633
730
  r_p = " " * (pad_t - (pad_t / 2))
@@ -637,6 +734,9 @@ class GRmenu
637
734
  lines << "#{color_code}#{v_l}#{reset_code} #{color_code}#{bar_line}#{reset_code} #{color_code}#{v_r}#{reset_code}"
638
735
  if @status && !@status.empty?
639
736
  st_str = @status.to_s
737
+ if GRmenu.display_width(st_str) > inner_w
738
+ st_str = st_str[0...[inner_w - 3, 1].max] + "..."
739
+ end
640
740
  pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
641
741
  st_line = st_str + (" " * pad_st)
642
742
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(st_line)} #{color_code}#{v_r}#{reset_code}"
@@ -662,7 +762,8 @@ class GRmenu
662
762
  end
663
763
  end
664
764
 
665
- def self.spinner(message = "Cargando...", color: "cyan", level: 2, delay: 0.08, &block)
765
+ def self.spinner(message_arg = nil, message: nil, color: "cyan", level: 2, delay: 0.08, &block)
766
+ actual_message = message || message_arg || "Cargando..."
666
767
  frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
667
768
  is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
668
769
  color_code = is_rgb ? "" : ansi_color(color, level)
@@ -674,7 +775,7 @@ class GRmenu
674
775
  while !stop_spinner
675
776
  f = frames[frame_idx % frames.length]
676
777
  f_color = is_rgb ? rgb_color(frame_idx * 0.3) : color_code
677
- msg_out = is_rgb ? Color.rgb(message, frame_idx * 0.1) : message
778
+ msg_out = is_rgb ? Color.rgb(actual_message, frame_idx * 0.1) : actual_message
678
779
  Kernel.print("\r\e[K#{f_color}#{f}#{reset_code} #{msg_out}")
679
780
  $stdout.flush
680
781
  frame_idx += 1
@@ -688,13 +789,13 @@ class GRmenu
688
789
  stop_spinner = true
689
790
  spinner_thread.join
690
791
  success_color = ansi_color("green", 2)
691
- Kernel.print("\r\e[K#{success_color}[OK]#{reset_code} #{message} #{Color.gray("Listo!")}\r\n")
792
+ Kernel.print("\r\e[K#{success_color}[OK]#{reset_code} #{actual_message} #{Color.gray("Listo!")}\r\n")
692
793
  result
693
794
  rescue Exception => e
694
795
  stop_spinner = true
695
796
  spinner_thread.join rescue nil
696
797
  error_color = ansi_color("red", 2)
697
- Kernel.print("\r\e[K#{error_color}[ERROR]#{reset_code} #{message} #{Color.bright_red("(Error: #{e.message})")}\r\n")
798
+ Kernel.print("\r\e[K#{error_color}[ERROR]#{reset_code} #{actual_message} #{Color.bright_red("(Error: #{e.message})")}\r\n")
698
799
  raise e
699
800
  ensure
700
801
  stop_spinner = true
@@ -702,8 +803,9 @@ class GRmenu
702
803
  end
703
804
  end
704
805
 
705
- def self.progress(total = 100, title: nil, color: "cyan", level: 2, style: 3, width: nil, &block)
706
- bar = ProgressBar.new(total, title: title, color: color, level: level, style: style, width: width)
806
+ def self.progress(total_arg = nil, total: nil, title: nil, color: "cyan", level: 2, style: 3, width: nil, &block)
807
+ actual_total = total || total_arg || 100
808
+ bar = ProgressBar.new(actual_total, title: title, color: color, level: level, style: style, width: width)
707
809
  Kernel.print(HIDE_CURSOR)
708
810
  bar.render
709
811
  begin
@@ -715,10 +817,11 @@ class GRmenu
715
817
  end
716
818
  end
717
819
 
718
- def self.confirm(question = "¿Confirmar acción?", default: true, color: "cyan", style: 3)
820
+ def self.confirm(question_arg = nil, question: nil, default: true, color: "cyan", style: 3)
821
+ actual_question = question || question_arg || "¿Confirmar acción?"
719
822
  choice = default ? 0 : 1
720
823
  term_w = terminal_width
721
- q_w = display_width(question)
824
+ q_w = display_width(actual_question)
722
825
  box_w = [q_w + 8, term_w - 4, 38].max
723
826
  box_w = [box_w, 64].min
724
827
  inner_w = box_w - 4
@@ -748,20 +851,24 @@ class GRmenu
748
851
  right_p = " " * (pad_total - (pad_total / 2))
749
852
  btn_formatted_line = "#{left_p}#{btn_yes} #{btn_no}#{right_p}"
750
853
 
751
- pad_q = [inner_w - display_width(question), 0].max
854
+ q_clean = actual_question.to_s
855
+ if display_width(q_clean) > inner_w
856
+ q_clean = q_clean[0...[inner_w - 3, 1].max] + "..."
857
+ end
858
+ pad_q = [inner_w - display_width(q_clean), 0].max
752
859
  q_left = " " * (pad_q / 2)
753
860
  q_right = " " * (pad_q - (pad_q / 2))
754
861
 
755
862
  lines = []
756
863
  if is_rgb
757
864
  lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")
758
- lines << "#{Color.rgb(v_l)} #{q_left}#{question}#{q_right} #{Color.rgb(v_r)}"
865
+ lines << "#{Color.rgb(v_l)} #{q_left}#{q_clean}#{q_right} #{Color.rgb(v_r)}"
759
866
  lines << "#{Color.rgb(v_l)} #{' ' * inner_w} #{Color.rgb(v_r)}"
760
867
  lines << "#{Color.rgb(v_l)} #{btn_formatted_line} #{Color.rgb(v_r)}"
761
868
  lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
762
869
  else
763
870
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
764
- lines << "#{color_code}#{v_l}#{reset_code} #{q_left}#{question}#{q_right} #{color_code}#{v_r}#{reset_code}"
871
+ lines << "#{color_code}#{v_l}#{reset_code} #{q_left}#{q_clean}#{q_right} #{color_code}#{v_r}#{reset_code}"
765
872
  lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
766
873
  lines << "#{color_code}#{v_l}#{reset_code} #{btn_formatted_line} #{color_code}#{v_r}#{reset_code}"
767
874
  lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
@@ -812,54 +919,98 @@ class GRmenu
812
919
  result
813
920
  end
814
921
 
815
- def self.input(prompt_text = "Ingresa un valor:", default: "", password: false, color: "cyan", style: 3)
816
- text = String.new(default.to_s)
817
- term_w = terminal_width
818
- p_w = display_width(prompt_text)
819
- box_w = [p_w + 8, term_w - 4, 42].max
820
- box_w = [box_w, 64].min
821
- inner_w = box_w - 4
922
+ def self.input(prompt_or_title = nil, title: nil, label: nil, default: "", password: false, color: nil, border_color: nil, title_color: nil, label_color: nil, style: nil, width: nil)
923
+ c_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "input")) || {}
924
+ style_num = (style || c_sec["style"] || 3).to_i
925
+ border_cfg = BORDERS[style_num] || BORDERS[3]
822
926
 
823
- border_cfg = BORDERS[style] || BORDERS[3]
824
927
  h_top = border_cfg[:ht] || border_cfg[:h]
825
928
  h_bot = border_cfg[:hb] || border_cfg[:h]
826
929
  v_l = border_cfg[:vl] || border_cfg[:v]
827
930
  v_r = border_cfg[:vr] || border_cfg[:v]
828
931
 
829
- is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
830
- color_code = is_rgb ? "" : ansi_color(color, 2)
831
- reset_code = ansi_reset
932
+ box_title = if title && !title.to_s.empty?
933
+ title.to_s
934
+ elsif prompt_or_title && !label
935
+ prompt_or_title.to_s
936
+ else
937
+ c_sec["title"] || "Entrada de Datos"
938
+ end
939
+
940
+ input_label = if label && !label.to_s.empty?
941
+ label.to_s
942
+ elsif prompt_or_title && title
943
+ prompt_or_title.to_s
944
+ elsif c_sec["label"]
945
+ c_sec["label"].to_s
946
+ else
947
+ "Valor:"
948
+ end
949
+
950
+ brd_col_name = (border_color || color || c_sec["border_color"] || c_sec["color"] || "cyan").to_s
951
+ tit_col_name = (title_color || c_sec["title_color"] || "yellow").to_s
952
+ lbl_col_name = (label_color || c_sec["label_color"] || "white").to_s
953
+
954
+ is_rgb = (brd_col_name.downcase == "rgb" || brd_col_name.downcase == "rainbow" || brd_col_name.downcase == "chroma")
832
955
 
833
- top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
834
- bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
956
+ text = String.new(default.to_s)
957
+ term_w = terminal_width
958
+ p_len = display_width(box_title) + 6
959
+ c_len = display_width(input_label) + display_width(text) + 12
960
+ box_w = width ? width.to_i : [p_len, c_len, 48].max
961
+ box_w = [box_w, term_w - 4].min
962
+ inner_w = [box_w - 2, 20].max
835
963
 
836
964
  drawn_lines = 0
837
965
 
838
966
  render_input = lambda do
839
967
  display_str = password ? ("*" * text.length) : text
840
- input_raw = "> #{display_str}█"
841
- pad_in = [inner_w - display_width(input_raw), 0].max
842
- input_padded = input_raw + (" " * pad_in)
968
+ avail_inp_w = [inner_w - display_width(input_label) - 4, 4].max
969
+ if display_width(display_str) > avail_inp_w
970
+ display_str = "..." + display_str[-[avail_inp_w - 3, 1].max..-1]
971
+ end
843
972
 
844
- pad_p = [inner_w - display_width(prompt_text), 0].max
845
- p_left = " " * (pad_p / 2)
846
- p_right = " " * (pad_p - (pad_p / 2))
973
+ brd_code = is_rgb ? "" : ansi_color(brd_col_name, 1)
974
+ tit_code = ansi_color(tit_col_name, 2)
975
+ lbl_code = ansi_color(lbl_col_name, 2)
976
+ rst = ansi_reset
847
977
 
848
- lines = []
849
- if is_rgb
850
- lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")
851
- lines << "#{Color.rgb(v_l)} #{Color.bright_yellow(p_left + prompt_text + p_right)} #{Color.rgb(v_r)}"
852
- lines << "#{Color.rgb(v_l)} #{' ' * inner_w} #{Color.rgb(v_r)}"
853
- lines << "#{Color.rgb(v_l)} #{Color.bright_white(input_padded)} #{Color.rgb(v_r)}"
854
- lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
855
- else
856
- lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
857
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(p_left + prompt_text + p_right)} #{color_code}#{v_r}#{reset_code}"
858
- lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
859
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_white(input_padded)} #{color_code}#{v_r}#{reset_code}"
860
- lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
861
- end
978
+ t_clean = " #{box_title} "
979
+ t_w = display_width(t_clean)
980
+ l_pad = [(inner_w - t_w) / 2, 0].max
981
+ r_pad = [inner_w - t_w - l_pad, 0].max
982
+
983
+ top_fill_l = (h_top * l_pad)[0...l_pad]
984
+ top_fill_r = (h_top * r_pad)[0...r_pad]
985
+ bot_fill = (h_bot * inner_w)[0...inner_w]
986
+
987
+ top_line = if is_rgb
988
+ Color.rgb("#{border_cfg[:tl]}#{top_fill_l}") + tit_code + t_clean + Color.rgb("#{top_fill_r}#{border_cfg[:tr]}")
989
+ else
990
+ "#{brd_code}#{border_cfg[:tl]}#{top_fill_l}#{rst}#{tit_code}#{t_clean}#{rst}#{brd_code}#{top_fill_r}#{border_cfg[:tr]}#{rst}"
991
+ end
862
992
 
993
+ empty_line = if is_rgb
994
+ Color.rgb("#{v_l}#{' ' * inner_w}#{v_r}")
995
+ else
996
+ "#{brd_code}#{v_l}#{rst}#{' ' * inner_w}#{brd_code}#{v_r}#{rst}"
997
+ end
998
+
999
+ raw_content = " #{input_label} #{display_str}█"
1000
+ content_pad = [inner_w - display_width(raw_content), 0].max
1001
+ content_line = if is_rgb
1002
+ "#{Color.rgb(v_l)} #{lbl_code}#{input_label}#{rst} #{Color.bright_white(display_str)}█#{' ' * content_pad}#{Color.rgb(v_r)}"
1003
+ else
1004
+ "#{brd_code}#{v_l}#{rst} #{lbl_code}#{input_label}#{rst} #{Color.bright_white(display_str)}█#{' ' * content_pad}#{brd_code}#{v_r}#{rst}"
1005
+ end
1006
+
1007
+ bot_line = if is_rgb
1008
+ Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
1009
+ else
1010
+ "#{brd_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{rst}"
1011
+ end
1012
+
1013
+ lines = [top_line, empty_line, content_line, empty_line, bot_line]
863
1014
  frame = lines.join("\r\n") + "\r\n"
864
1015
  Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
865
1016
  Kernel.print(frame)
@@ -885,7 +1036,7 @@ class GRmenu
885
1036
  text.clear
886
1037
  render_input.call
887
1038
  elsif key =~ /^[[:print:]]$/
888
- text << key if text.length < (inner_w - 4)
1039
+ text << key if display_width(text) < (inner_w - display_width(input_label) - 6)
889
1040
  render_input.call
890
1041
  end
891
1042
  end
@@ -903,9 +1054,13 @@ class GRmenu
903
1054
  text
904
1055
  end
905
1056
 
906
- def self.checkbox(items, title: "Selección Múltiple", subtitle: "Espacio: Marcar/Desmarcar | a: Todos | n: Ninguno | i: Invertir | Enter: Confirmar", color: "cyan", style: 3, page_size: 8, min_width: nil, preselected: [])
907
- item_list = items.is_a?(Array) ? items : Array(items)
1057
+ def self.checkbox(items_arg = nil, items: nil, title: "Selección Múltiple", subtitle: "Espacio: Marcar/Desmarcar | a: Todos | n: Ninguno | i: Invertir | Enter: Confirmar", color: nil, style: nil, page_size: nil, min_width: nil, preselected: [])
1058
+ cb_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "checkbox")) || {}
1059
+ actual_items = items || items_arg || []
1060
+ item_list = actual_items.is_a?(Array) ? actual_items : Array(actual_items)
908
1061
  return [] if item_list.empty?
1062
+ chk_mark = cb_sec["checked_mark"] || "[X]"
1063
+ unchk_mark = cb_sec["unchecked_mark"] || "[ ]"
909
1064
 
910
1065
  parsed_items = item_list.map do |it|
911
1066
  case it
@@ -936,8 +1091,10 @@ class GRmenu
936
1091
  index = 0
937
1092
  rgb_tick = 0.0
938
1093
  drawn_lines = 0
939
- is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
940
- border_cfg = BORDERS[style] || BORDERS[3]
1094
+ cb_color = (color || cb_sec["color"] || "cyan").to_s
1095
+ style_num = (style || cb_sec["style"] || 3).to_i
1096
+ is_rgb = (cb_color.downcase == "rgb" || cb_color.downcase == "rainbow" || cb_color.downcase == "chroma")
1097
+ border_cfg = BORDERS[style_num] || BORDERS[3]
941
1098
  h_top = border_cfg[:ht] || border_cfg[:h]
942
1099
  h_bot = border_cfg[:hb] || border_cfg[:h]
943
1100
  v_l = border_cfg[:vl] || border_cfg[:v]
@@ -972,8 +1129,10 @@ class GRmenu
972
1129
  if is_rgb
973
1130
  lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", rgb_tick)
974
1131
  unless title.to_s.empty?
975
- pad_t = [inner_w - display_width(title), 0].max
976
- t_line = (" " * (pad_t / 2)) + title + (" " * (pad_t - (pad_t / 2)))
1132
+ t_clean = title.to_s
1133
+ t_clean = t_clean[0...[inner_w - 3, 1].max] + "..." if display_width(t_clean) > inner_w
1134
+ pad_t = [inner_w - display_width(t_clean), 0].max
1135
+ t_line = (" " * (pad_t / 2)) + t_clean + (" " * (pad_t - (pad_t / 2)))
977
1136
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(t_line, rgb_tick + 0.2)} #{Color.rgb(v_r, rgb_tick)}"
978
1137
  lines << Color.rgb("#{v_l}#{mid_fill}#{v_r}", rgb_tick)
979
1138
  end
@@ -984,11 +1143,13 @@ class GRmenu
984
1143
  end
985
1144
  (start_idx..end_idx).each do |i|
986
1145
  it = parsed_items[i]
987
- mark = it[:checked] ? "[X]" : "[ ]"
1146
+ mark = it[:checked] ? chk_mark : unchk_mark
988
1147
  is_active = (i == index)
989
- raw_line = "#{is_active ? '> ' : ' '}#{mark} #{it[:name]}"
990
- pad_l = [inner_w - display_width(raw_line), 0].max
991
- line_padded = raw_line + (" " * pad_l)
1148
+ max_name_w = [inner_w - display_width(mark) - 4, 4].max
1149
+ name_str = it[:name].to_s
1150
+ name_str = name_str[0...[max_name_w - 3, 1].max] + "..." if display_width(name_str) > max_name_w
1151
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{name_str}"
1152
+ line_padded = pad_to_width(raw_line, inner_w)
992
1153
  if is_active
993
1154
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(line_padded, rgb_tick + 0.4)} #{Color.rgb(v_r, rgb_tick)}"
994
1155
  elsif it[:checked]
@@ -1005,18 +1166,22 @@ class GRmenu
1005
1166
  end
1006
1167
  unless subtitle.to_s.empty?
1007
1168
  lines << Color.rgb("#{v_l}#{mid_fill}#{v_r}", rgb_tick)
1008
- pad_sub = [inner_w - display_width(subtitle), 0].max
1009
- sub_padded = (" " * (pad_sub / 2)) + subtitle + (" " * (pad_sub - (pad_sub / 2)))
1169
+ s_clean = subtitle.to_s
1170
+ s_clean = s_clean[0...[inner_w - 3, 1].max] + "..." if display_width(s_clean) > inner_w
1171
+ pad_sub = [inner_w - display_width(s_clean), 0].max
1172
+ sub_padded = (" " * (pad_sub / 2)) + s_clean + (" " * (pad_sub - (pad_sub / 2)))
1010
1173
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(sub_padded)} #{Color.rgb(v_r, rgb_tick)}"
1011
1174
  end
1012
1175
  lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", rgb_tick)
1013
1176
  else
1014
- color_code = ansi_color(color, 2)
1177
+ color_code = ansi_color(cb_color, 2)
1015
1178
  reset_code = ansi_reset
1016
1179
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
1017
1180
  unless title.to_s.empty?
1018
- pad_t = [inner_w - display_width(title), 0].max
1019
- t_line = (" " * (pad_t / 2)) + title + (" " * (pad_t - (pad_t / 2)))
1181
+ t_clean = title.to_s
1182
+ t_clean = t_clean[0...[inner_w - 3, 1].max] + "..." if display_width(t_clean) > inner_w
1183
+ pad_t = [inner_w - display_width(t_clean), 0].max
1184
+ t_line = (" " * (pad_t / 2)) + t_clean + (" " * (pad_t - (pad_t / 2)))
1020
1185
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(t_line)} #{color_code}#{v_r}#{reset_code}"
1021
1186
  lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
1022
1187
  end
@@ -1027,15 +1192,17 @@ class GRmenu
1027
1192
  end
1028
1193
  (start_idx..end_idx).each do |i|
1029
1194
  it = parsed_items[i]
1030
- mark = it[:checked] ? "[X]" : "[ ]"
1195
+ mark = it[:checked] ? chk_mark : unchk_mark
1031
1196
  is_active = (i == index)
1032
- raw_line = "#{is_active ? '> ' : ' '}#{mark} #{it[:name]}"
1033
- pad_l = [inner_w - display_width(raw_line), 0].max
1034
- line_padded = raw_line + (" " * pad_l)
1197
+ max_name_w = [inner_w - display_width(mark) - 4, 4].max
1198
+ name_str = it[:name].to_s
1199
+ name_str = name_str[0...[max_name_w - 3, 1].max] + "..." if display_width(name_str) > max_name_w
1200
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{name_str}"
1201
+ line_padded = pad_to_width(raw_line, inner_w)
1035
1202
  if is_active
1036
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1203
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(line_padded)} #{color_code}#{v_r}#{reset_code}"
1037
1204
  elsif it[:checked]
1038
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1205
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1039
1206
  else
1040
1207
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.white(line_padded)} #{color_code}#{v_r}#{reset_code}"
1041
1208
  end
@@ -1048,8 +1215,10 @@ class GRmenu
1048
1215
  end
1049
1216
  unless subtitle.to_s.empty?
1050
1217
  lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
1051
- pad_sub = [inner_w - display_width(subtitle), 0].max
1052
- sub_padded = (" " * (pad_sub / 2)) + subtitle + (" " * (pad_sub - (pad_sub / 2)))
1218
+ s_clean = subtitle.to_s
1219
+ s_clean = s_clean[0...[inner_w - 3, 1].max] + "..." if display_width(s_clean) > inner_w
1220
+ pad_sub = [inner_w - display_width(s_clean), 0].max
1221
+ sub_padded = (" " * (pad_sub / 2)) + s_clean + (" " * (pad_sub - (pad_sub / 2)))
1053
1222
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(sub_padded)} #{color_code}#{v_r}#{reset_code}"
1054
1223
  end
1055
1224
  lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
@@ -1139,14 +1308,18 @@ class GRmenu
1139
1308
  alias_method :multiselect, :checkbox
1140
1309
  end
1141
1310
 
1142
- def self.slider(prompt = "Selecciona un valor:", min: 0, max: 100, step: 1, default: nil, unit: "", color: "cyan", style: 3, width: 46)
1311
+ def self.slider(prompt_arg = nil, prompt: nil, min: 0, max: 100, step: 1, default: nil, unit: "", color: nil, style: nil, width: 46)
1312
+ actual_prompt = prompt || prompt_arg || "Selecciona un valor:"
1313
+ sl_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "slider")) || {}
1143
1314
  val = (default || min).to_f.clamp(min.to_f, max.to_f)
1144
1315
  step_val = [step.to_f, 0.001].max
1145
1316
  drawn_lines = 0
1146
1317
  rgb_tick = 0.0
1147
- is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
1318
+ sl_color = (color || sl_sec["color"] || "cyan").to_s
1319
+ style_num = (style || sl_sec["style"] || 3).to_i
1320
+ is_rgb = (sl_color.downcase == "rgb" || sl_color.downcase == "rainbow" || sl_color.downcase == "chroma")
1148
1321
 
1149
- border_cfg = BORDERS[style] || BORDERS[3]
1322
+ border_cfg = BORDERS[style_num] || BORDERS[3]
1150
1323
  h_top = border_cfg[:ht] || border_cfg[:h]
1151
1324
  h_bot = border_cfg[:hb] || border_cfg[:h]
1152
1325
  v_l = border_cfg[:vl] || border_cfg[:v]
@@ -1154,7 +1327,7 @@ class GRmenu
1154
1327
 
1155
1328
  render_slider = lambda do
1156
1329
  term_w = terminal_width
1157
- box_w = [width, term_w - 4, display_width(prompt) + 8, 38].max
1330
+ box_w = [width, term_w - 4, display_width(actual_prompt) + 8, 38].max
1158
1331
  box_w = [box_w, term_w - 2].min
1159
1332
  inner_w = box_w - 4
1160
1333
 
@@ -1168,14 +1341,21 @@ class GRmenu
1168
1341
  range_span = 1.0 if range_span <= 0
1169
1342
  fraction = ((val - min).to_f / range_span).clamp(0.0, 1.0)
1170
1343
 
1171
- bar_w = [inner_w - val_str.length - 5, 10].max
1172
- filled_len = (fraction * bar_w).round
1173
- empty_len = bar_w - filled_len
1344
+ avail_bar_w = [inner_w - display_width(val_str) - 4, 6].max
1345
+ filled_len = (fraction * avail_bar_w).round
1346
+ empty_len = [avail_bar_w - filled_len, 0].max
1174
1347
 
1175
- pad_p = [inner_w - display_width(prompt), 0].max
1176
- p_line = (" " * (pad_p / 2)) + prompt + (" " * (pad_p - (pad_p / 2)))
1348
+ p_clean = actual_prompt.to_s
1349
+ if display_width(p_clean) > inner_w
1350
+ p_clean = p_clean[0...[inner_w - 3, 1].max] + "..."
1351
+ end
1352
+ pad_p = [inner_w - display_width(p_clean), 0].max
1353
+ p_line = (" " * (pad_p / 2)) + p_clean + (" " * (pad_p - (pad_p / 2)))
1177
1354
 
1178
- instr = "← / → Ajustar | Enter Guardar"
1355
+ instr = (inner_w >= 30) ? "← / → Ajustar | Enter Guardar" : "←/→: Ajustar | Enter: Ok"
1356
+ if display_width(instr) > inner_w
1357
+ instr = instr[0...[inner_w - 3, 1].max] + "..."
1358
+ end
1179
1359
  pad_i = [inner_w - display_width(instr), 0].max
1180
1360
  i_line = (" " * (pad_i / 2)) + instr + (" " * (pad_i - (pad_i / 2)))
1181
1361
 
@@ -1188,22 +1368,24 @@ class GRmenu
1188
1368
  filled_part = Color.rgb("█" * filled_len, rgb_tick + 0.5)
1189
1369
  empty_part = Color.gray("░" * empty_len)
1190
1370
  bar_raw = "[#{filled_part}#{empty_part}] #{Color.bright_white(val_str)}"
1191
- pad_b = [inner_w - (bar_w + 3 + val_str.length), 0].max
1371
+ bar_vis_w = 2 + filled_len + empty_len + 1 + display_width(val_str)
1372
+ pad_b = [inner_w - bar_vis_w, 0].max
1192
1373
  lines << "#{Color.rgb(v_l, rgb_tick)} #{bar_raw}#{' ' * pad_b} #{Color.rgb(v_r, rgb_tick)}"
1193
1374
  lines << "#{Color.rgb(v_l, rgb_tick)} #{' ' * inner_w} #{Color.rgb(v_r, rgb_tick)}"
1194
1375
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(i_line)} #{Color.rgb(v_r, rgb_tick)}"
1195
1376
  lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", rgb_tick)
1196
1377
  else
1197
- color_code = ansi_color(color, 2)
1378
+ color_code = ansi_color(sl_color, 2)
1198
1379
  reset_code = ansi_reset
1199
1380
 
1200
1381
  bar_raw = "[#{"█" * filled_len}#{"░" * empty_len}] #{val_str}"
1201
1382
  pad_b = [inner_w - display_width(bar_raw), 0].max
1383
+ bar_line = bar_raw + (" " * pad_b)
1202
1384
 
1203
1385
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
1204
1386
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(p_line)} #{color_code}#{v_r}#{reset_code}"
1205
1387
  lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
1206
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_cyan(bar_raw)}#{' ' * pad_b} #{color_code}#{v_r}#{reset_code}"
1388
+ lines << "#{color_code}#{v_l}#{reset_code} #{bar_line} #{color_code}#{v_r}#{reset_code}"
1207
1389
  lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
1208
1390
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(i_line)} #{color_code}#{v_r}#{reset_code}"
1209
1391
  lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
@@ -1280,32 +1462,47 @@ class GRmenu
1280
1462
  end
1281
1463
 
1282
1464
  def self.read_key_raw(input_stream)
1283
- unless input_stream.respond_to?(:tty?) && input_stream.tty?
1284
- begin
1285
- return input_stream.sysread(3) if input_stream.respond_to?(:sysread)
1286
- return input_stream.read(1)
1287
- rescue EOFError, Errno::EPIPE
1288
- return nil
1289
- end
1465
+ is_tty = input_stream.respond_to?(:tty?) && input_stream.tty?
1466
+ first_char = nil
1467
+ begin
1468
+ first_char = is_tty ? input_stream.getch : input_stream.read(1)
1469
+ rescue EOFError, Errno::EPIPE
1470
+ return nil
1290
1471
  end
1291
-
1292
- first_char = input_stream.getch
1293
1472
  return nil if first_char.nil?
1294
1473
 
1295
1474
  if first_char == "\e"
1296
- begin
1297
- extra_chars = input_stream.read_nonblock(2)
1298
- first_char << extra_chars
1299
- rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1475
+ seq = first_char.dup
1476
+ if is_tty
1477
+ while (ch = (input_stream.getch(min: 0, time: 0.1) rescue nil))
1478
+ seq << ch
1479
+ break if seq =~ /[a-zA-Z~]\z/
1480
+ end
1481
+ else
1482
+ while (IO.select([input_stream], nil, nil, 0.05) rescue nil)
1483
+ ch = (input_stream.read(1) rescue nil)
1484
+ break if ch.nil?
1485
+ seq << ch
1486
+ break if seq =~ /[a-zA-Z~]\z/
1487
+ end
1300
1488
  end
1301
- elsif first_char == "\x00" || first_char == "\xe0"
1302
- begin
1303
- second_char = input_stream.read_nonblock(1)
1304
- first_char << second_char
1305
- rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1306
- second_char = input_stream.getch rescue nil
1307
- first_char << second_char if second_char
1489
+
1490
+ if seq == "\e[M"
1491
+ cb = is_tty ? (input_stream.getch(min: 0, time: 0.1) rescue nil) : (input_stream.read(1) rescue nil)
1492
+ cx = is_tty ? (input_stream.getch(min: 0, time: 0.1) rescue nil) : (input_stream.read(1) rescue nil)
1493
+ cy = is_tty ? (input_stream.getch(min: 0, time: 0.1) rescue nil) : (input_stream.read(1) rescue nil)
1494
+ if cb && cx && cy
1495
+ btn_c = cb.ord - 32
1496
+ col_c = cx.ord - 32
1497
+ row_c = cy.ord - 32
1498
+ return "\e[<#{btn_c};#{col_c};#{row_c}M"
1499
+ end
1308
1500
  end
1501
+
1502
+ return seq
1503
+ elsif first_char == "\x00" || first_char == "\xe0"
1504
+ second_char = is_tty ? (input_stream.getch(min: 0, time: 0.1) rescue nil) : (input_stream.read(1) rescue nil)
1505
+ return first_char + second_char if second_char
1309
1506
  end
1310
1507
 
1311
1508
  first_char
@@ -1322,7 +1519,8 @@ class GRmenu
1322
1519
  banner: { color: "magenta", level: 2 },
1323
1520
  subtitle: { color: "cyan", level: 2 },
1324
1521
  divider: { color: "blue", level: 1 },
1325
- font: 1
1522
+ font: 1,
1523
+ desc_prefix: "[i]"
1326
1524
  )
1327
1525
  @border = border.dup
1328
1526
  @options = options.dup
@@ -1332,11 +1530,22 @@ class GRmenu
1332
1530
  @subtitle = subtitle.dup
1333
1531
  @divider = divider.dup
1334
1532
  @font = font.to_i
1533
+ @desc_prefix = desc_prefix.to_s
1534
+ end
1535
+
1536
+ def desc_prefix(prefix_str = nil)
1537
+ return @desc_prefix if prefix_str.nil?
1538
+ @desc_prefix = prefix_str.to_s
1539
+ self
1335
1540
  end
1541
+ alias_method :description_prefix, :desc_prefix
1542
+ alias_method :desc_prefix=, :desc_prefix
1543
+ alias_method :description_prefix=, :desc_prefix
1336
1544
 
1337
1545
  def border(color_name = nil, brightness_level = 1)
1338
1546
  return @border if color_name.nil?
1339
1547
  @border = parse_color(color_name, brightness_level)
1548
+ self
1340
1549
  end
1341
1550
  alias_method :Border, :border
1342
1551
  alias_method :set_border, :border
@@ -1345,6 +1554,7 @@ class GRmenu
1345
1554
  def options(color_name = nil, brightness_level = 1)
1346
1555
  return @options if color_name.nil?
1347
1556
  @options = parse_color(color_name, brightness_level)
1557
+ self
1348
1558
  end
1349
1559
  alias_method :Options, :options
1350
1560
  alias_method :set_options, :options
@@ -1353,6 +1563,7 @@ class GRmenu
1353
1563
  def focus(color_name = nil, brightness_level = 2)
1354
1564
  return @focus if color_name.nil?
1355
1565
  @focus = parse_color(color_name, brightness_level)
1566
+ self
1356
1567
  end
1357
1568
  alias_method :Focus, :focus
1358
1569
  alias_method :set_focus, :focus
@@ -1361,6 +1572,7 @@ class GRmenu
1361
1572
  def title(color_name = nil, brightness_level = 2)
1362
1573
  return @title if color_name.nil?
1363
1574
  @title = parse_color(color_name, brightness_level)
1575
+ self
1364
1576
  end
1365
1577
  alias_method :Title, :title
1366
1578
  alias_method :set_title, :title
@@ -1369,6 +1581,7 @@ class GRmenu
1369
1581
  def banner(color_name = nil, brightness_level = 2)
1370
1582
  return @banner if color_name.nil?
1371
1583
  @banner = parse_color(color_name, brightness_level)
1584
+ self
1372
1585
  end
1373
1586
  alias_method :Banner, :banner
1374
1587
  alias_method :set_banner, :banner
@@ -1377,6 +1590,7 @@ class GRmenu
1377
1590
  def subtitle(color_name = nil, brightness_level = 2)
1378
1591
  return @subtitle if color_name.nil?
1379
1592
  @subtitle = parse_color(color_name, brightness_level)
1593
+ self
1380
1594
  end
1381
1595
  alias_method :Subtitle, :subtitle
1382
1596
  alias_method :set_subtitle, :subtitle
@@ -1385,6 +1599,7 @@ class GRmenu
1385
1599
  def divider(color_name = nil, brightness_level = 1)
1386
1600
  return @divider if color_name.nil?
1387
1601
  @divider = parse_color(color_name, brightness_level)
1602
+ self
1388
1603
  end
1389
1604
  alias_method :Divider, :divider
1390
1605
  alias_method :set_divider, :divider
@@ -1393,6 +1608,7 @@ class GRmenu
1393
1608
  def font(font_id = nil)
1394
1609
  return @font if font_id.nil?
1395
1610
  @font = font_id.to_i
1611
+ self
1396
1612
  end
1397
1613
  alias_method :Font, :font
1398
1614
  alias_method :set_font, :font
@@ -1473,6 +1689,15 @@ class GRmenu
1473
1689
  alias_method :Font, :font
1474
1690
  alias_method :set_font, :font
1475
1691
  alias_method :font=, :font
1692
+
1693
+ def desc_prefix(prefix_str = nil)
1694
+ @default_desc_prefix ||= "[i]"
1695
+ return @default_desc_prefix if prefix_str.nil?
1696
+ @default_desc_prefix = prefix_str.to_s
1697
+ end
1698
+ alias_method :description_prefix, :desc_prefix
1699
+ alias_method :desc_prefix=, :desc_prefix
1700
+ alias_method :description_prefix=, :desc_prefix
1476
1701
  end
1477
1702
  end
1478
1703
 
@@ -1524,6 +1749,713 @@ class GRmenu
1524
1749
  alias_method :clr, :clear_screen
1525
1750
  end
1526
1751
 
1752
+ @@global_theme = {}
1753
+
1754
+ def self.current_theme
1755
+ @@global_theme
1756
+ end
1757
+
1758
+ def self.parse_config_text(text)
1759
+ data = { global: {}, sections: {} }
1760
+ current_sec = nil
1761
+ current_sec_data = {}
1762
+
1763
+ text.to_s.each_line do |line|
1764
+ line = line.strip
1765
+ next if line.empty? || line.start_with?("#")
1766
+ next if line.start_with?("GRmenu::config")
1767
+
1768
+ if line.start_with?("<<")
1769
+ sec_name = line[2..-1].strip.downcase
1770
+ current_sec = sec_name
1771
+ current_sec_data = {}
1772
+ elsif line == ">>"
1773
+ if current_sec
1774
+ data[:sections][current_sec] = current_sec_data
1775
+ current_sec = nil
1776
+ end
1777
+ elsif line.include?("::")
1778
+ key, _, val = line.partition("::")
1779
+ key = key.strip.sub(/^@/, '').downcase
1780
+ val = val.strip.sub(/^["']/, '').sub(/["']$/, '')
1781
+ if current_sec
1782
+ current_sec_data[key] = val
1783
+ else
1784
+ data[:global][key] = val
1785
+ end
1786
+ end
1787
+ end
1788
+ data
1789
+ end
1790
+
1791
+ def self.find_theme_file(path_or_name)
1792
+ name = path_or_name.to_s
1793
+ candidates = [
1794
+ name,
1795
+ "#{name}.gr",
1796
+ find_data_file("themes/#{name}.gr"),
1797
+ find_data_file("themes/#{name}"),
1798
+ File.expand_path("data/themes/#{name}.gr", __dir__),
1799
+ File.expand_path("data/themes/#{name}", __dir__),
1800
+ File.expand_path("../data/themes/#{name}.gr", __dir__),
1801
+ File.expand_path("../data/themes/#{name}", __dir__)
1802
+ ].compact
1803
+ candidates.find { |p| File.exist?(p) }
1804
+ end
1805
+
1806
+ def self.import_config(path_or_name)
1807
+ path = find_theme_file(path_or_name)
1808
+ raise "No se encontro el tema: #{path_or_name}" unless path && File.exist?(path)
1809
+ text = File.read(path)
1810
+ parsed = parse_config_text(text)
1811
+ apply_parsed_theme(parsed)
1812
+ @@global_theme = parsed
1813
+ path
1814
+ end
1815
+
1816
+ def self.theme(name)
1817
+ import_config(name)
1818
+ end
1819
+
1820
+ def self.extract_color_and_level(val, default_level = 1)
1821
+ return ["white", default_level] if val.nil?
1822
+ parts = val.to_s.split(":")
1823
+ c_name = parts[0].to_s.strip
1824
+ lvl = parts[1] ? parts[1].to_i : default_level
1825
+ [c_name, lvl]
1826
+ end
1827
+
1828
+ def self.apply_parsed_theme(parsed)
1829
+ sec = parsed[:sections] || {}
1830
+ glob = parsed[:global] || {}
1831
+ m = (sec["menu"] || {}).merge(glob)
1832
+
1833
+ if m && !m.empty?
1834
+ if m["border"] || m["border_color"]
1835
+ c, l = extract_color_and_level(m["border"] || m["border_color"], 1)
1836
+ SetStyle.border(c, l)
1837
+ end
1838
+ if m["title"] || m["title_color"]
1839
+ c, l = extract_color_and_level(m["title"] || m["title_color"], 2)
1840
+ SetStyle.title(c, l)
1841
+ end
1842
+ if m["focus"] || m["focus_color"]
1843
+ c, l = extract_color_and_level(m["focus"] || m["focus_color"], 2)
1844
+ SetStyle.focus(c, l)
1845
+ end
1846
+ if m["options"] || m["options_color"]
1847
+ c, l = extract_color_and_level(m["options"] || m["options_color"], 1)
1848
+ SetStyle.options(c, l)
1849
+ end
1850
+ if m["banner"] || m["banner_color"]
1851
+ c, l = extract_color_and_level(m["banner"] || m["banner_color"], 2)
1852
+ SetStyle.banner(c, l)
1853
+ end
1854
+ if m["subtitle"] || m["subtitle_color"]
1855
+ c, l = extract_color_and_level(m["subtitle"] || m["subtitle_color"], 1)
1856
+ SetStyle.subtitle(c, l)
1857
+ end
1858
+ if m["divider"] || m["divider_color"]
1859
+ c, l = extract_color_and_level(m["divider"] || m["divider_color"], 1)
1860
+ SetStyle.divider(c, l)
1861
+ end
1862
+ if m["desc_prefix"] || m["description_prefix"] || m["prefix"]
1863
+ SetStyle.desc_prefix(m["desc_prefix"] || m["description_prefix"] || m["prefix"])
1864
+ end
1865
+ SetStyle.font(m["font"].to_i) if m["font"]
1866
+ end
1867
+
1868
+ sec.each do |k, v|
1869
+ next unless v.is_a?(Hash)
1870
+ c_val = v["color"] || v["border"] || v["options"] || v["focus"] || v["title"] || v["banner"] || v["subtitle"] || v["divider"]
1871
+ c, l = extract_color_and_level(c_val, (v["level"] || 1).to_i)
1872
+ case k
1873
+ when "border"
1874
+ SetStyle.border(c, l)
1875
+ when "options"
1876
+ SetStyle.options(c, l)
1877
+ when "focus"
1878
+ SetStyle.focus(c, l)
1879
+ when "title"
1880
+ SetStyle.title(c, l)
1881
+ when "banner"
1882
+ SetStyle.banner(c, l)
1883
+ when "subtitle"
1884
+ SetStyle.subtitle(c, l)
1885
+ when "divider"
1886
+ SetStyle.divider(c, l)
1887
+ end
1888
+ end
1889
+ SetStyle.font(glob["font"].to_i) if glob["font"]
1890
+ end
1891
+
1892
+ def self.style(css_content)
1893
+ parsed = parse_config_text(css_content)
1894
+ apply_parsed_theme(parsed)
1895
+ parsed
1896
+ end
1897
+
1898
+ def self.export_config(path = nil)
1899
+ if path.nil?
1900
+ caller_loc = caller_locations.find { |c| !c.path.include?(__FILE__) }
1901
+ base = caller_loc ? caller_loc.path.sub(/\.rb$/, '') : "theme"
1902
+ path = "#{base}.gr"
1903
+ end
1904
+ lines = ["GRmenu::config<-1->", ""]
1905
+ lines << "@theme:: \"#{File.basename(path, '.gr').capitalize}\""
1906
+ lines << "@author:: \"grcode\""
1907
+ lines << "@version:: \"1.0\""
1908
+ lines << ""
1909
+ lines << "<<menu"
1910
+ lines << " style:: 3"
1911
+ lines << " banner_style:: 3"
1912
+ lines << " font:: #{SetStyle.font}"
1913
+ lines << " animate:: rgb"
1914
+ lines << " center:: true"
1915
+ lines << " border:: #{SetStyle.border[:color]}:#{SetStyle.border[:level]}"
1916
+ lines << " title:: #{SetStyle.title[:color]}:#{SetStyle.title[:level]}"
1917
+ lines << " focus:: #{SetStyle.focus[:color]}:#{SetStyle.focus[:level]}"
1918
+ lines << " options:: #{SetStyle.options[:color]}:#{SetStyle.options[:level]}"
1919
+ lines << " banner:: #{SetStyle.banner[:color]}:#{SetStyle.banner[:level]}"
1920
+ lines << " subtitle:: #{SetStyle.subtitle[:color]}:#{SetStyle.subtitle[:level]}"
1921
+ lines << " divider:: #{SetStyle.divider[:color]}:#{SetStyle.divider[:level]}"
1922
+ lines << ">>"
1923
+ lines << ""
1924
+ lines << "<<table"
1925
+ lines << " style:: 3"
1926
+ lines << " header_color:: yellow:2"
1927
+ lines << " border_color:: rgb:2"
1928
+ lines << " selected_row:: green:2"
1929
+ lines << " row_color:: white:1"
1930
+ lines << " zebra_striping:: true"
1931
+ lines << ">>"
1932
+ lines << ""
1933
+ lines << "<<card"
1934
+ lines << " style:: 7"
1935
+ lines << " border_color:: cyan:2"
1936
+ lines << " title_color:: yellow:2"
1937
+ lines << " content_color:: white:1"
1938
+ lines << ">>"
1939
+ lines << ""
1940
+ lines << "<<slider"
1941
+ lines << " style:: 3"
1942
+ lines << " color:: rgb:2"
1943
+ lines << " fill_char:: █"
1944
+ lines << " empty_char:: ░"
1945
+ lines << ">>"
1946
+ lines << ""
1947
+ lines << "<<checkbox"
1948
+ lines << " style:: 3"
1949
+ lines << " color:: rgb:2"
1950
+ lines << " checked_mark:: [X]"
1951
+ lines << " unchecked_mark:: [ ]"
1952
+ lines << ">>"
1953
+ lines << ""
1954
+ File.write(path, lines.join("\n") + "\n")
1955
+ path
1956
+ end
1957
+
1958
+ def self.export_from_file(source_file, target_path = nil)
1959
+ raise "No existe #{source_file}" unless File.exist?(source_file)
1960
+ orig_draw = instance_method(:draw) rescue nil
1961
+ extracted = nil
1962
+ define_method(:draw) do |*|
1963
+ extracted = {
1964
+ style: @style,
1965
+ banner_style: @banner_style,
1966
+ font: @style_config&.font,
1967
+ animate: @animate,
1968
+ border: @style_config&.border,
1969
+ title: @style_config&.title,
1970
+ focus: @style_config&.focus,
1971
+ options: @style_config&.options,
1972
+ banner: @style_config&.banner,
1973
+ subtitle: @style_config&.subtitle,
1974
+ divider: @style_config&.divider
1975
+ }
1976
+ throw :grmenu_export_completed
1977
+ end
1978
+ begin
1979
+ catch(:grmenu_export_completed) do
1980
+ load(File.expand_path(source_file))
1981
+ end
1982
+ ensure
1983
+ define_method(:draw, orig_draw) if orig_draw
1984
+ end
1985
+ out = target_path || source_file.sub(/\.rb$/, '') + ".gr"
1986
+ if extracted && extracted[:border]
1987
+ lines = ["GRmenu::config<-1->", ""]
1988
+ lines << "@theme:: \"#{File.basename(out, '.gr').capitalize}\""
1989
+ lines << "@author:: \"grcode\""
1990
+ lines << "@version:: \"1.0\""
1991
+ lines << ""
1992
+ lines << "<<menu"
1993
+ lines << " style:: #{extracted[:style] || 3}"
1994
+ lines << " banner_style:: #{extracted[:banner_style] || 3}"
1995
+ lines << " font:: #{extracted[:font] || 1}"
1996
+ lines << " animate:: #{extracted[:animate] || 'rgb'}"
1997
+ lines << " center:: true"
1998
+ lines << " border:: #{extracted[:border][:color]}:#{extracted[:border][:level]}"
1999
+ lines << " title:: #{extracted[:title][:color]}:#{extracted[:title][:level]}"
2000
+ lines << " focus:: #{extracted[:focus][:color]}:#{extracted[:focus][:level]}"
2001
+ lines << " options:: #{extracted[:options][:color]}:#{extracted[:options][:level]}"
2002
+ lines << " banner:: #{extracted[:banner][:color]}:#{extracted[:banner][:level]}"
2003
+ lines << " subtitle:: #{extracted[:subtitle][:color]}:#{extracted[:subtitle][:level]}"
2004
+ lines << " divider:: #{extracted[:divider][:color]}:#{extracted[:divider][:level]}"
2005
+ lines << ">>"
2006
+ lines << ""
2007
+ lines << "<<table"
2008
+ lines << " style:: #{extracted[:style] || 3}"
2009
+ lines << " header_color:: yellow:2"
2010
+ lines << " border_color:: rgb:2"
2011
+ lines << " selected_row:: green:2"
2012
+ lines << " row_color:: white:1"
2013
+ lines << " zebra_striping:: true"
2014
+ lines << ">>"
2015
+ lines << ""
2016
+ lines << "<<card"
2017
+ lines << " style:: 7"
2018
+ lines << " border_color:: cyan:2"
2019
+ lines << " title_color:: yellow:2"
2020
+ lines << " content_color:: white:1"
2021
+ lines << ">>"
2022
+ lines << ""
2023
+ lines << "<<slider"
2024
+ lines << " style:: 3"
2025
+ lines << " color:: rgb:2"
2026
+ lines << " fill_char:: █"
2027
+ lines << " empty_char:: ░"
2028
+ lines << ">>"
2029
+ lines << ""
2030
+ lines << "<<checkbox"
2031
+ lines << " style:: 3"
2032
+ lines << " color:: rgb:2"
2033
+ lines << " checked_mark:: [X]"
2034
+ lines << " unchecked_mark:: [ ]"
2035
+ lines << ">>"
2036
+ lines << ""
2037
+ File.write(out, lines.join("\n") + "\n")
2038
+ out
2039
+ else
2040
+ export_config(out)
2041
+ end
2042
+ end
2043
+
2044
+ def self.split_ansi_chars(str)
2045
+ segments = []
2046
+ current_style = String.new("")
2047
+ in_escape = false
2048
+ escape_buf = String.new("")
2049
+
2050
+ str.to_s.each_char do |ch|
2051
+ if ch == "\e"
2052
+ in_escape = true
2053
+ escape_buf << ch
2054
+ next
2055
+ end
2056
+ if in_escape
2057
+ escape_buf << ch
2058
+ if ch =~ /[a-zA-Z]/
2059
+ in_escape = false
2060
+ current_style = escape_buf.dup
2061
+ escape_buf.clear
2062
+ end
2063
+ next
2064
+ end
2065
+ segments << { char: ch, style: current_style.dup }
2066
+ end
2067
+ segments
2068
+ end
2069
+
2070
+ def self.animate_render(lines, type = :diagonal, delay = 0.012)
2071
+ type_str = type.to_s.downcase
2072
+ return if lines.nil? || lines.empty?
2073
+ rst = ansi_reset
2074
+
2075
+ case type_str
2076
+ when "diagonal"
2077
+ parsed_rows = lines.map { |l| split_ansi_chars(l) }
2078
+ max_len = parsed_rows.map(&:length).max || 0
2079
+ total_steps = max_len + (parsed_rows.length * 2)
2080
+ step = 0
2081
+ while step <= total_steps
2082
+ buffer = String.new(CURSOR_HOME)
2083
+ parsed_rows.each_with_index do |row_segs, y|
2084
+ rendered_row = String.new("")
2085
+ row_segs.each_with_index do |seg, x|
2086
+ if (x + y * 2) <= step
2087
+ rendered_row << seg[:style] << seg[:char] << rst
2088
+ else
2089
+ rendered_row << " "
2090
+ end
2091
+ end
2092
+ buffer << rendered_row << CLEAR_TO_EOL << "\r\n"
2093
+ end
2094
+ buffer << CLEAR_TO_EOS
2095
+ Kernel.print(buffer)
2096
+ $stdout.flush
2097
+ sleep(delay)
2098
+ step += 4
2099
+ end
2100
+ when "linear"
2101
+ buffer = String.new(CURSOR_HOME)
2102
+ lines.each do |line|
2103
+ Kernel.print("#{line}#{CLEAR_TO_EOL}\r\n")
2104
+ $stdout.flush
2105
+ sleep(delay * 3)
2106
+ end
2107
+ when "fade"
2108
+ [1, 2].each do |lvl|
2109
+ buffer = String.new(CURSOR_HOME)
2110
+ lines.each do |line|
2111
+ clean = line.gsub(/\e\[[0-9;]*m/, '')
2112
+ buffer << ansi_color("white", lvl) << clean << rst << CLEAR_TO_EOL << "\r\n"
2113
+ end
2114
+ buffer << CLEAR_TO_EOS
2115
+ Kernel.print(buffer)
2116
+ $stdout.flush
2117
+ sleep(delay * 8)
2118
+ end
2119
+ end
2120
+ end
2121
+
2122
+ def self.alert(type, message, title: nil, style: 3, color: nil, border_color: nil, title_color: nil, pause: true)
2123
+ type_sym = type.to_sym rescue :info
2124
+ tag, def_col, def_title = case type_sym
2125
+ when :success, :ok
2126
+ ["[✔ EXITO]", "green", "Operacion Exitosa"]
2127
+ when :error, :fail, :danger
2128
+ ["[✖ ERROR]", "red", "Error en el Sistema"]
2129
+ when :warning, :warn
2130
+ ["[⚠ AVISO]", "yellow", "Advertencia"]
2131
+ else
2132
+ ["[ℹ INFO]", "cyan", "Informacion"]
2133
+ end
2134
+ card_col = border_color || color || def_col
2135
+ card_title = title || "#{tag} #{def_title}"
2136
+ card(title: card_title, content: message, style: style, color: card_col, title_color: title_color, pause: pause)
2137
+ end
2138
+
2139
+ def self.card(title_or_content = nil, content_arg = nil, title: nil, content: nil, style: nil, color: nil, border_color: nil, title_color: nil, content_color: nil, width: nil, pause: false)
2140
+ c_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "card")) || {}
2141
+ actual_title = title || (content_arg ? title_or_content : nil)
2142
+ actual_content = (content || (content_arg ? content_arg : title_or_content) || "").to_s
2143
+ style_num = (style || c_sec["style"] || 7).to_i
2144
+ border_cfg = BORDERS[style_num] || BORDERS[7]
2145
+ card_color = (border_color || color || c_sec["border_color"] || c_sec["color"] || "cyan").to_s
2146
+ actual_title_color = (title_color || c_sec["title_color"] || "yellow").to_s
2147
+ actual_content_color = (content_color || c_sec["content_color"] || "white").to_s
2148
+ is_rgb = card_color.downcase == "rgb" || card_color.downcase == "rainbow" || card_color.downcase == "chroma"
2149
+
2150
+ lines = actual_content.split("\n")
2151
+ content_max = lines.map { |l| display_width(l) }.max || 0
2152
+ box_w = width || [content_max + 6, actual_title ? display_width(actual_title) + 6 : 0, 46].max
2153
+ box_w = [box_w, terminal_width - 2].min
2154
+ inner_w = box_w - 2
2155
+
2156
+ wrapped_lines = []
2157
+ lines.each do |raw_l|
2158
+ if display_width(raw_l) <= (inner_w - 2)
2159
+ wrapped_lines << raw_l
2160
+ else
2161
+ cur = String.new("")
2162
+ raw_l.split(" ").each do |w|
2163
+ if cur.empty?
2164
+ cur << w
2165
+ elsif display_width("#{cur} #{w}") <= (inner_w - 2)
2166
+ cur << " " << w
2167
+ else
2168
+ wrapped_lines << cur
2169
+ cur = String.new(w)
2170
+ end
2171
+ end
2172
+ wrapped_lines << cur unless cur.empty?
2173
+ end
2174
+ end
2175
+
2176
+ tl = border_cfg[:tl] || "#"
2177
+ tr = border_cfg[:tr] || "#"
2178
+ bl = border_cfg[:bl] || "#"
2179
+ br = border_cfg[:br] || "#"
2180
+ h_char = border_cfg[:h] || "─"
2181
+ v_char = border_cfg[:v] || "│"
2182
+
2183
+ brd_col = is_rgb ? Color.rgb("").sub(/\e\[0m$/, '') : ansi_color(card_color, 1)
2184
+ rst = ansi_reset
2185
+
2186
+ top_str = if actual_title && !actual_title.empty?
2187
+ t_clean = " #{actual_title} "
2188
+ t_len = display_width(t_clean)
2189
+ if t_len > inner_w
2190
+ t_clean = " #{actual_title[0...[inner_w - 6, 1].max]}... "
2191
+ t_len = display_width(t_clean)
2192
+ end
2193
+ l_len = [(inner_w - t_len) / 2, 0].max
2194
+ r_len = [inner_w - t_len - l_len, 0].max
2195
+ h_char * l_len + ansi_color(actual_title_color, 2) + t_clean + brd_col + h_char * r_len
2196
+ else
2197
+ h_char * inner_w
2198
+ end
2199
+
2200
+ out = +""
2201
+ out << "#{brd_col}#{tl}#{top_str}#{tr}#{rst}\r\n"
2202
+ wrapped_lines.each do |line|
2203
+ pad_line = " " + line
2204
+ out << "#{brd_col}#{v_char}#{rst}#{ansi_color(actual_content_color, 1)}#{pad_to_width(pad_line, inner_w)}#{rst}#{brd_col}#{v_char}#{rst}\r\n"
2205
+ end
2206
+ out << "#{brd_col}#{bl}#{h_char * inner_w}#{br}#{rst}\r\n"
2207
+
2208
+ Kernel.print(out)
2209
+ self.continue if pause
2210
+ end
2211
+
2212
+ def self.table(headers_arg = nil, rows_arg = nil, headers: nil, rows: nil, title: nil, style: nil, color: nil, header_color: nil, border_color: nil, selected_row: nil, page_size: nil, search: false, sort: false, animate: nil, width: nil, **kwargs)
2213
+ t_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "table")) || {}
2214
+ input_stream = $stdin
2215
+ output_stream = $stdout
2216
+ style_num = (style || t_sec["style"] || 3).to_i
2217
+ border_cfg = BORDERS[style_num] || BORDERS[3]
2218
+ tbl_color = (border_color || color || t_sec["border_color"] || t_sec["border"] || t_sec["color"] || "cyan").to_s
2219
+ header_color = header_color || t_sec["header_color"] || "yellow"
2220
+ focus_color = selected_row || t_sec["selected_row"] || t_sec["focus"] || "green"
2221
+ page_size ||= (t_sec["page_size"] || 8).to_i
2222
+
2223
+ resolved_headers = (headers || headers_arg || []).map(&:to_s)
2224
+ resolved_raw_rows = (rows || rows_arg || []).map { |r| r.is_a?(Array) ? r.map(&:to_s) : r.values.map(&:to_s) }
2225
+ headers = resolved_headers
2226
+ raw_rows = resolved_raw_rows
2227
+ filtered_rows = raw_rows.dup
2228
+ selected_idx = 0
2229
+ query = String.new("")
2230
+ sort_col = nil
2231
+ sort_asc = true
2232
+ tick = 0.0
2233
+
2234
+ calc_widths = lambda do
2235
+ col_counts = [headers.length, raw_rows.map(&:length).max || 0].max
2236
+ widths = Array.new(col_counts, 0)
2237
+ headers.each_with_index { |h, i| widths[i] = [widths[i], display_width(h)].max }
2238
+ filtered_rows.each do |row|
2239
+ row.each_with_index { |cell, i| widths[i] = [widths[i], display_width(cell)].max }
2240
+ end
2241
+ widths.map { |w| w + 2 }
2242
+ end
2243
+
2244
+ draw_table = lambda do |t_tick|
2245
+ is_rgb = tbl_color.downcase == "rgb" || tbl_color.downcase == "rainbow" || tbl_color.downcase == "chroma"
2246
+ brd_color = is_rgb ? rgb_color(t_tick, 0.0) : ansi_color(tbl_color, 1)
2247
+ hdr_color = is_rgb ? rgb_color(t_tick, 0.8) : ansi_color(header_color, 2)
2248
+ foc_color = is_rgb ? rgb_color(t_tick, 1.4) : ansi_color(focus_color, 2)
2249
+ rst = ansi_reset
2250
+
2251
+ col_w = calc_widths.call
2252
+ help_line = " ↑/↓: Moverse | Enter: Elegir | s: Ordenar | Esc: Salir"
2253
+ tot_w = [col_w.sum + (col_w.length - 1) + 4, title ? display_width(title) + 8 : 0, display_width(help_line) + 4, 46].max
2254
+ tot_w = [tot_w, terminal_width - 2].min
2255
+ inner_w = tot_w - 2
2256
+
2257
+ if inner_w < display_width(help_line)
2258
+ help_line = " ↑/↓: Mover | Enter: Ok | Esc: Salir"
2259
+ end
2260
+
2261
+ tl = border_cfg[:tl] || "#"
2262
+ tr = border_cfg[:tr] || "#"
2263
+ bl = border_cfg[:bl] || "#"
2264
+ br = border_cfg[:br] || "#"
2265
+ h_char = border_cfg[:h] || "─"
2266
+ v_char = border_cfg[:v] || "│"
2267
+
2268
+ top_str = if title && !title.empty?
2269
+ t_clean = " #{title} "
2270
+ t_len = display_width(t_clean)
2271
+ if t_len > inner_w
2272
+ t_clean = " #{title[0...[(inner_w - 6), 1].max]}... "
2273
+ t_len = display_width(t_clean)
2274
+ end
2275
+ left_len = [(inner_w - t_len) / 2, 0].max
2276
+ right_len = [inner_w - t_len - left_len, 0].max
2277
+ h_char * left_len + t_clean + h_char * right_len
2278
+ else
2279
+ h_char * inner_w
2280
+ end
2281
+
2282
+ out = String.new(CURSOR_HOME)
2283
+ out << HIDE_CURSOR
2284
+ out << "#{brd_color}#{tl}#{top_str}#{tr}#{rst}#{CLEAR_TO_EOL}\r\n"
2285
+
2286
+ if search
2287
+ s_line = " Buscar: #{query}█"
2288
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(s_line, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2289
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2290
+ end
2291
+
2292
+ hdr_cells = headers.each_with_index.map do |h, i|
2293
+ w = col_w[i] || 10
2294
+ sort_indicator = sort_col == i ? (sort_asc ? " ▲" : " ▼") : ""
2295
+ h_str = "#{h}#{sort_indicator}"
2296
+ max_c = [w - 2, 2].max
2297
+ h_str = h_str[0...[max_c - 2, 1].max] + ".." if display_width(h_str) > max_c
2298
+ pad_to_width(" #{h_str}", w)
2299
+ end
2300
+ hdr_row_str = " " + hdr_cells.join("│")
2301
+ hdr_row_str = hdr_row_str[0...inner_w] if display_width(hdr_row_str) > inner_w
2302
+ out << "#{brd_color}#{v_char}#{rst}#{hdr_color}#{pad_to_width(hdr_row_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2303
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2304
+
2305
+ max_visible = page_size || 8
2306
+ total_rows = filtered_rows.length
2307
+ if total_rows == 0
2308
+ empty_msg = " (Sin registros que coincidan con '#{query}')"
2309
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(empty_msg, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2310
+ else
2311
+ start_idx = [(selected_idx - max_visible / 2), 0].max
2312
+ start_idx = [start_idx, [total_rows - max_visible, 0].max].min
2313
+ end_idx = [start_idx + max_visible - 1, total_rows - 1].min
2314
+
2315
+ if start_idx > 0
2316
+ up_str = " ▲ (+#{start_idx} arriba)"
2317
+ out << "#{brd_color}#{v_char}#{rst}#{ansi_color('gray', 1)}#{pad_to_width(up_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2318
+ end
2319
+
2320
+ (start_idx..end_idx).each do |r_i|
2321
+ row = filtered_rows[r_i]
2322
+ is_active = (r_i == selected_idx)
2323
+ prefix = is_active ? "> " : " "
2324
+
2325
+ row_cells = row.each_with_index.map do |cell, c_i|
2326
+ w = col_w[c_i] || 10
2327
+ c_str = cell.to_s
2328
+ max_c = [w - 2, 2].max
2329
+ c_str = c_str[0...[max_c - 2, 1].max] + ".." if display_width(c_str) > max_c
2330
+ pad_to_width(" #{c_str}", w)
2331
+ end
2332
+ row_str = prefix + row_cells.join("│")[1..-1].to_s
2333
+ row_str = row_str[0...inner_w] if display_width(row_str) > inner_w
2334
+
2335
+ if is_active
2336
+ out << "#{brd_color}#{v_char}#{rst}#{foc_color}#{pad_to_width(row_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2337
+ else
2338
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(row_str, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2339
+ end
2340
+ end
2341
+
2342
+ remaining_down = total_rows - 1 - end_idx
2343
+ if remaining_down > 0
2344
+ down_str = " ▼ (+#{remaining_down} abajo)"
2345
+ out << "#{brd_color}#{v_char}#{rst}#{ansi_color('gray', 1)}#{pad_to_width(down_str, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2346
+ end
2347
+ end
2348
+
2349
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2350
+ out << "#{brd_color}#{v_char}#{rst}#{ansi_color('gray', 1)}#{pad_to_width(help_line, inner_w)}#{rst}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2351
+ out << "#{brd_color}#{bl}#{h_char * inner_w}#{br}#{rst}#{CLEAR_TO_EOL}\r\n"
2352
+ out << CLEAR_TO_EOS
2353
+ output_stream.print(out)
2354
+ output_stream.flush
2355
+ end
2356
+
2357
+ loop_res = nil
2358
+ reader = lambda do |stream|
2359
+ loop do
2360
+ draw_table.call(tick)
2361
+ is_anim = color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma"
2362
+ if is_anim
2363
+ ready = false
2364
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
2365
+ begin
2366
+ res = IO.select([stream], nil, nil, 0.035)
2367
+ ready = true if res && res[0] && !res[0].empty?
2368
+ rescue StandardError
2369
+ ready = true
2370
+ end
2371
+ else
2372
+ ready = true
2373
+ end
2374
+ unless ready
2375
+ tick += 0.08
2376
+ next
2377
+ end
2378
+ end
2379
+
2380
+ key = read_key_raw(stream)
2381
+ break if key.nil? || key == "\x03" || key == "\x04"
2382
+
2383
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
2384
+ if filtered_rows.length > 0
2385
+ selected_idx = (selected_idx - 1) % filtered_rows.length
2386
+ end
2387
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
2388
+ if filtered_rows.length > 0
2389
+ selected_idx = (selected_idx + 1) % filtered_rows.length
2390
+ end
2391
+ elsif key == "\e[5~" || key == "\e[D"
2392
+ if filtered_rows.length > 0
2393
+ selected_idx = [(selected_idx - (page_size || 8)), 0].max
2394
+ end
2395
+ elsif key == "\e[6~" || key == "\e[C"
2396
+ if filtered_rows.length > 0
2397
+ selected_idx = [(selected_idx + (page_size || 8)), filtered_rows.length - 1].min
2398
+ end
2399
+ elsif key == "\r" || key == "\n"
2400
+ if filtered_rows.length > 0
2401
+ loop_res = filtered_rows[selected_idx]
2402
+ end
2403
+ break
2404
+ elsif key == "\e"
2405
+ if search && !query.empty?
2406
+ query.clear
2407
+ filtered_rows = raw_rows.dup
2408
+ selected_idx = 0
2409
+ else
2410
+ loop_res = nil
2411
+ break
2412
+ end
2413
+ elsif key == "\x7f" || key == "\b" || key == "\x08"
2414
+ if search && !query.empty?
2415
+ query.chop!
2416
+ if query.empty?
2417
+ filtered_rows = raw_rows.dup
2418
+ else
2419
+ filtered_rows = raw_rows.select { |r| r.any? { |c| c.downcase.include?(query.downcase) } }
2420
+ end
2421
+ selected_idx = 0
2422
+ end
2423
+ elsif key == "\x15"
2424
+ if search
2425
+ query.clear
2426
+ filtered_rows = raw_rows.dup
2427
+ selected_idx = 0
2428
+ end
2429
+ elsif (!search || query.empty?) && (key == "s" || key == "S")
2430
+ if sort
2431
+ sort_col = ((sort_col || -1) + 1) % [headers.length, 1].max
2432
+ filtered_rows.sort_by! { |r| r[sort_col] || "" }
2433
+ selected_idx = 0
2434
+ end
2435
+ elsif (!search || query.empty?) && (key == "q" || key == "Q")
2436
+ loop_res = nil
2437
+ break
2438
+ elsif search && key =~ /^[[:print:]]$/
2439
+ query << key
2440
+ filtered_rows = raw_rows.select { |r| r.any? { |c| c.downcase.include?(query.downcase) } }
2441
+ selected_idx = 0
2442
+ end
2443
+ end
2444
+ end
2445
+
2446
+ begin
2447
+ output_stream.print("#{HIDE_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
2448
+ if input_stream.respond_to?(:raw) && input_stream.respond_to?(:tty?) && input_stream.tty?
2449
+ input_stream.raw { |s| reader.call(s) }
2450
+ else
2451
+ reader.call(input_stream)
2452
+ end
2453
+ ensure
2454
+ output_stream.print("#{SHOW_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
2455
+ end
2456
+ loop_res
2457
+ end
2458
+
1527
2459
  def self.div(long = nil, color = "blue", level = 1, char = "─")
1528
2460
  width = long || [terminal_width - 2, 64].min
1529
2461
  if color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma"
@@ -1648,10 +2580,25 @@ class GRmenu
1648
2580
  class << self
1649
2581
  alias_method :message, :banner
1650
2582
  alias_method :logo, :banner
2583
+
2584
+ def tabs(tabs_hash, *args, **kwargs)
2585
+ new([], *args, tabs: tabs_hash, **kwargs)
2586
+ end
1651
2587
  end
1652
2588
 
1653
- def initialize(functions, *positional_arguments, title: nil, banner: nil, subtitle: nil, description: nil, divider: nil, style: nil, banner_style: nil, center: true, font: nil, page_size: nil, search: false, columns: 1, image: nil, image_width: nil, **keyword_arguments)
1654
- @functions = functions.is_a?(Array) ? functions : Array(functions)
2589
+ def initialize(functions = [], *positional_arguments, title: nil, banner: nil, subtitle: nil, description: nil, divider: nil, style: nil, banner_style: nil, center: true, font: nil, page_size: nil, search: false, columns: 1, image: nil, image_width: nil, mouse: nil, tabs: nil, active_tab_color: nil, tab_color: nil, **keyword_arguments)
2590
+ tabs_data = tabs || keyword_arguments[:tabs] || (functions.is_a?(Hash) ? functions : nil)
2591
+ if tabs_data.is_a?(Hash) && !tabs_data.empty?
2592
+ @tabs = tabs_data.keys.map(&:to_s)
2593
+ @tab_contents = tabs_data.transform_keys(&:to_s)
2594
+ @active_tab_idx = 0
2595
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
2596
+ else
2597
+ @tabs = nil
2598
+ @tab_contents = nil
2599
+ @active_tab_idx = nil
2600
+ @functions = functions.is_a?(Array) ? functions : Array(functions)
2601
+ end
1655
2602
 
1656
2603
  pos_title = positional_arguments[0]
1657
2604
  pos_style = positional_arguments[1]
@@ -1660,22 +2607,60 @@ class GRmenu
1660
2607
  @banner = (banner || keyword_arguments[:banner] || "").to_s
1661
2608
  @subtitle = (subtitle || description || keyword_arguments[:subtitle] || keyword_arguments[:description] || "").to_s
1662
2609
  @divider = divider.nil? ? (!@banner.empty? || !@subtitle.empty?) : divider
1663
- @style = (style || pos_style || keyword_arguments[:style] || 19).to_i
1664
- @banner_style = (banner_style || keyword_arguments[:banner_style] || 3).to_i
1665
- @center = center.nil? ? true : center
2610
+
2611
+ theme_menu_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, 'menu')) || {}
2612
+ th_style = theme_menu_sec['style']&.to_i
2613
+ th_bstyle = theme_menu_sec['banner_style']&.to_i
2614
+
2615
+ @style = (style || pos_style || keyword_arguments[:style] || th_style || 19).to_i
2616
+ @banner_style = (banner_style || keyword_arguments[:banner_style] || th_bstyle || 3).to_i
2617
+ @center = center.nil? ? (theme_menu_sec.key?('center') ? (theme_menu_sec['center'].to_s != 'false') : true) : center
1666
2618
  @page_size = (page_size || keyword_arguments[:page_size])&.to_i
1667
2619
  @search = search || keyword_arguments[:search] || false
1668
2620
  @columns = [(columns || keyword_arguments[:columns] || 1).to_i, 1].max
1669
2621
  @image = image || keyword_arguments[:image]
1670
2622
  @image_width = (image_width || keyword_arguments[:image_width])&.to_i
2623
+ @animate = (keyword_arguments[:animate] || (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, 'menu', 'animate')) || false).to_s
2624
+ m_val = mouse.nil? ? keyword_arguments[:mouse] : mouse
2625
+ if m_val.nil?
2626
+ @mouse = theme_menu_sec.key?('mouse') ? (theme_menu_sec['mouse'].to_s != 'false') : true
2627
+ else
2628
+ @mouse = (m_val == true || m_val.to_s == 'true')
2629
+ end
2630
+
2631
+ t_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "tabs")) || {}
2632
+ @active_tab_color = (active_tab_color || keyword_arguments[:active_tab_color] || t_sec["active_tab"] || t_sec["active_tab_color"] || "yellow").to_s
2633
+ @tab_color = (tab_color || keyword_arguments[:tab_color] || t_sec["tab_color"] || t_sec["inactive_tab"] || t_sec["color"] || "gray").to_s
2634
+
1671
2635
  @query = String.new("")
1672
2636
  @index = 0
1673
2637
  @rgb_tick = 0.0
1674
2638
 
2639
+ @level = 0
2640
+ @open_level = 0
2641
+ @sub_index_1 = 0
2642
+ @sub_index_2 = 0
2643
+ @sub_1_hit_map = {}
2644
+ @sub_2_hit_map = {}
2645
+ @sub_1_col_rng = nil
2646
+ @sub_2_col_rng = nil
2647
+
2648
+ @active_panel = :main
2649
+ @sub_index = 0
2650
+ @submenu_open = false
2651
+
2652
+ @row_hit_map = {}
2653
+ @tab_ranges = {}
2654
+ @tabs_row = nil
2655
+ @up_arrow_row = nil
2656
+ @down_arrow_row = nil
2657
+ @sub_hit_map = {}
2658
+
1675
2659
  @cached_image_lines = nil
1676
2660
  @cached_image_cols = nil
1677
2661
 
1678
2662
  init_font = font || keyword_arguments[:font_style] || SetStyle.font || 1
2663
+ init_pfx = @desc_prefix || (@@global_theme.is_a?(Hash) && (@@global_theme.dig(:sections, 'menu', 'desc_prefix') || @@global_theme.dig(:sections, 'menu', 'prefix'))) || SetStyle.desc_prefix || "[i]"
1679
2664
 
1680
2665
  @style_config = SetStyle.new(
1681
2666
  border: SetStyle.border.dup,
@@ -1685,7 +2670,8 @@ class GRmenu
1685
2670
  banner: SetStyle.banner.dup,
1686
2671
  subtitle: SetStyle.subtitle.dup,
1687
2672
  divider: SetStyle.divider.dup,
1688
- font: init_font
2673
+ font: init_font,
2674
+ desc_prefix: init_pfx
1689
2675
  )
1690
2676
  end
1691
2677
 
@@ -1776,10 +2762,20 @@ class GRmenu
1776
2762
  def colorize(text, color_config, phase_offset = 0.0)
1777
2763
  return text.to_s if color_config.nil? || color_config.empty?
1778
2764
 
1779
- color_name = (color_config[:color] || color_config["color"]).to_s.downcase
2765
+ color_name = (color_config[:color] || color_config["color"]).to_s.downcase.strip
1780
2766
  brightness_level = (color_config[:level] || color_config["level"] || 1).to_i
1781
2767
 
1782
- if color_name == "rgb" || color_name == "rainbow" || color_name == "chroma"
2768
+ if color_name.include?(":")
2769
+ parts = color_name.split(":")
2770
+ color_name = parts[0].strip
2771
+ brightness_level = parts[1].to_i if parts[1] && !parts[1].empty?
2772
+ end
2773
+
2774
+ is_neon_color = color_name.start_with?("neon")
2775
+ is_anim_active = is_neon_color || (@animate && ["diagonal", "linear", "fade", "rgb", "rainbow", "chroma", "neon"].include?(@animate.to_s.downcase))
2776
+ is_chroma = color_name == "rgb" || color_name == "rainbow" || color_name == "chroma" || @animate.to_s.downcase == "rgb"
2777
+
2778
+ if is_chroma
1783
2779
  tick = @rgb_tick || 0.0
1784
2780
  out = String.new("")
1785
2781
  char_count = 0
@@ -1814,6 +2810,67 @@ class GRmenu
1814
2810
  return out
1815
2811
  end
1816
2812
 
2813
+ if is_anim_active
2814
+ tick = @rgb_tick || 0.0
2815
+ base = if color_name =~ /\A#?([0-9a-f]{6})\z/i
2816
+ h = $1
2817
+ [h[0..1].to_i(16), h[2..3].to_i(16), h[4..5].to_i(16)]
2818
+ elsif color_name =~ /\A#?([0-9a-f]{3})\z/i
2819
+ h = $1
2820
+ [(h[0] * 2).to_i(16), (h[1] * 2).to_i(16), (h[2] * 2).to_i(16)]
2821
+ else
2822
+ BASE_RGB[color_name] || [255, 255, 255]
2823
+ end
2824
+
2825
+ if @animate.to_s.downcase == "fade"
2826
+ factor = (Math.sin(tick + phase_offset) + 1.0) / 2.0
2827
+ f = 0.35 + 0.65 * factor
2828
+ r = (base[0] * f).clamp(0, 255).to_i
2829
+ g = (base[1] * f).clamp(0, 255).to_i
2830
+ b = (base[2] * f).clamp(0, 255).to_i
2831
+ glow = (factor > 0.85) ? ";1" : ""
2832
+ return "\e[38;2;#{r};#{g};#{b}#{glow}m#{text}#{self.class.ansi_reset}"
2833
+ else
2834
+ out = String.new("")
2835
+ char_count = 0
2836
+ in_escape = false
2837
+ escape_buf = String.new("")
2838
+
2839
+ text.to_s.each_char do |ch|
2840
+ if ch == "\e"
2841
+ in_escape = true
2842
+ escape_buf << ch
2843
+ next
2844
+ end
2845
+ if in_escape
2846
+ escape_buf << ch
2847
+ if ch =~ /[a-zA-Z]/
2848
+ in_escape = false
2849
+ out << escape_buf
2850
+ escape_buf.clear
2851
+ end
2852
+ next
2853
+ end
2854
+
2855
+ if ch == " " || ch == "\t" || ch == "\r" || ch == "\n"
2856
+ out << ch
2857
+ else
2858
+ phase = char_count * 0.22 + phase_offset
2859
+ factor = (Math.sin(tick + phase) + 1.0) / 2.0
2860
+ f = 0.35 + 0.65 * factor
2861
+ r = (base[0] * f).clamp(0, 255).to_i
2862
+ g = (base[1] * f).clamp(0, 255).to_i
2863
+ b = (base[2] * f).clamp(0, 255).to_i
2864
+ glow = (factor > 0.85) ? ";1" : ""
2865
+ out << "\e[38;2;#{r};#{g};#{b}#{glow}m#{ch}"
2866
+ char_count += 1
2867
+ end
2868
+ end
2869
+ out << self.class.ansi_reset
2870
+ return out
2871
+ end
2872
+ end
2873
+
1817
2874
  color_code = self.class.ansi_color(color_name, brightness_level)
1818
2875
  return text.to_s unless color_code
1819
2876
 
@@ -1870,7 +2927,7 @@ class GRmenu
1870
2927
 
1871
2928
  lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg, 0.0)
1872
2929
  lines << colorize("#{v_l} #{l_p}#{clean_b}#{r_p} #{v_r}", banner_color_cfg, 0.4)
1873
- lines << colorize("#{banner_border[:bl]}#{bot_fill}#{border_cfg[:br]}", banner_color_cfg, 0.8)
2930
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg, 0.8)
1874
2931
  end
1875
2932
  [lines, box_w]
1876
2933
  end
@@ -1908,10 +2965,115 @@ class GRmenu
1908
2965
  @cached_image_lines
1909
2966
  end
1910
2967
 
2968
+ def is_submenu_item?(action)
2969
+ if action.is_a?(Array)
2970
+ target = action[1]
2971
+ return true if target.is_a?(Array) && (target.empty? || target.first.is_a?(Array) || target.first.is_a?(Proc) || target.first.is_a?(Method) || target.first.is_a?(Symbol))
2972
+ return true if target.is_a?(GRmenu)
2973
+ elsif action.is_a?(Hash)
2974
+ return true if action[:submenu] || action["submenu"]
2975
+ end
2976
+ false
2977
+ end
2978
+
2979
+ def get_submenu_actions(action)
2980
+ if action.is_a?(Array)
2981
+ action[1].is_a?(GRmenu) ? action[1].instance_variable_get(:@functions) : action[1]
2982
+ elsif action.is_a?(Hash)
2983
+ action[:submenu] || action["submenu"]
2984
+ end
2985
+ end
2986
+
2987
+ def render_submenu_box(sub_actions, parent_title = "", is_active: false, selected_idx: 0)
2988
+ sub_names = sub_actions.map { |a| extract_name_from_action(a) }
2989
+ max_name_len = sub_names.empty? ? 10 : sub_names.map { |n| GRmenu.display_width(n) }.max
2990
+ sub_title = parent_title.to_s
2991
+ sub_w = [max_name_len + 8, GRmenu.display_width(sub_title) + 6, 20].max
2992
+ sub_w = [sub_w, 36].min
2993
+ inner_w = [sub_w - 2, 8].max
2994
+
2995
+ s_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "submenu")) || {}
2996
+ sub_style = (s_sec["style"] || @style || 3).to_i
2997
+ sub_border = BORDERS[sub_style] || BORDERS[3]
2998
+ h_top = sub_border[:ht] || sub_border[:h]
2999
+ h_bot = sub_border[:hb] || sub_border[:h]
3000
+ v_l = sub_border[:vl] || sub_border[:v]
3001
+ v_r = sub_border[:vr] || sub_border[:v]
3002
+
3003
+ s_brd_col = s_sec["border"] || s_sec["border_color"] || @style_config.border[:color] || "cyan"
3004
+ s_foc_col = s_sec["focus"] || s_sec["focus_color"] || @style_config.focus[:color] || "yellow"
3005
+ s_opt_col = s_sec["options"] || s_sec["options_color"] || @style_config.options[:color] || "white"
3006
+
3007
+ s_brd_cfg = { color: s_brd_col, level: 1 }
3008
+ s_foc_cfg = { color: s_foc_col, level: 2 }
3009
+ s_opt_cfg = { color: s_opt_col, level: 1 }
3010
+ s_held_cfg = { color: "yellow", level: 1 }
3011
+
3012
+ lines = []
3013
+ top_fill = build_horizontal_line(h_top, inner_w)
3014
+ bot_fill = build_horizontal_line(h_bot, inner_w)
3015
+
3016
+ top_border_line = sub_border[:tl] + top_fill + sub_border[:tr]
3017
+ lines << colorize(top_border_line, s_brd_cfg, 0.0)
3018
+
3019
+ unless sub_title.empty?
3020
+ pad_t = [inner_w - 2 - GRmenu.display_width(sub_title), 0].max
3021
+ t_padded = " " * (pad_t / 2) + sub_title + " " * (pad_t - (pad_t / 2))
3022
+ c_title = colorize(t_padded, s_foc_cfg, 0.2)
3023
+ v_l_col = colorize(v_l, s_brd_cfg, 0.2)
3024
+ v_r_col = colorize(v_r, s_brd_cfg, 0.2)
3025
+ lines << "#{v_l_col} #{c_title} #{v_r_col}"
3026
+ mid_fill = build_horizontal_line(h_top, inner_w)
3027
+ lines << colorize(v_l + mid_fill + v_r, s_brd_cfg, 0.4)
3028
+ end
3029
+
3030
+ parent_item_row = nil
3031
+ sub_names.each_with_index do |s_name, s_idx|
3032
+ is_sub = is_submenu_item?(sub_actions[s_idx])
3033
+ arrow = is_sub ? "▶" : ""
3034
+ is_selected = (s_idx == selected_idx)
3035
+ parent_item_row = lines.length if is_selected
3036
+
3037
+ prefix = is_selected ? "> " : " "
3038
+ pad_s = [inner_w - 2 - prefix.length - GRmenu.display_width(s_name) - (is_sub ? 2 : 0), 0].max
3039
+ raw_item = if is_sub
3040
+ "#{prefix}#{s_name}#{' ' * pad_s} #{arrow}"
3041
+ else
3042
+ "#{prefix}#{s_name}#{' ' * pad_s}"
3043
+ end
3044
+
3045
+ item_cfg = if is_selected
3046
+ is_active ? s_foc_cfg : s_held_cfg
3047
+ else
3048
+ s_opt_cfg
3049
+ end
3050
+
3051
+ colored_item = colorize(raw_item, item_cfg, s_idx * 0.2)
3052
+ v_l_col = colorize(v_l, s_brd_cfg, 0.2)
3053
+ v_r_col = colorize(v_r, s_brd_cfg, 0.8)
3054
+ lines << "#{v_l_col} #{colored_item} #{v_r_col}"
3055
+ end
3056
+
3057
+ bot_border_line = sub_border[:bl] + bot_fill + sub_border[:br]
3058
+ lines << colorize(bot_border_line, s_brd_cfg, 0.6)
3059
+
3060
+ [lines, sub_w, parent_item_row]
3061
+ end
3062
+
1911
3063
  def render_lines(size_max = 20)
1912
3064
  term_cols = self.class.terminal_width
1913
3065
  term_rows = self.class.terminal_height
1914
3066
  rendered_lines = []
3067
+ @row_hit_map = {}
3068
+ @tab_ranges = {}
3069
+ @tabs_row = nil
3070
+ @up_arrow_row = nil
3071
+ @down_arrow_row = nil
3072
+ @sub_hit_map = {}
3073
+ @sub_1_hit_map = {}
3074
+ @sub_2_hit_map = {}
3075
+ @sub_1_col_rng = nil
3076
+ @sub_2_col_rng = nil
1915
3077
 
1916
3078
  header_box_width = 0
1917
3079
  header_lines_count = 0
@@ -2019,6 +3181,39 @@ class GRmenu
2019
3181
  avail_w = [total_width - 4, 1].max
2020
3182
  col_w = [(avail_w - (cols - 1) * 2) / cols, 1].max
2021
3183
 
3184
+ if @tabs && !@tabs.empty?
3185
+ tab_strs = []
3186
+ raw_tab_strs = []
3187
+ @tab_ranges = {}
3188
+ @tabs.each_with_index do |t_name, t_i|
3189
+ is_active = (t_i == @active_tab_idx)
3190
+ raw_t = is_active ? "[ #{t_name} ]" : " #{t_name} "
3191
+ colored_t = if is_active
3192
+ colorize(raw_t, { color: @active_tab_color, level: 2 }, 0.0)
3193
+ else
3194
+ colorize(raw_t, { color: @tab_color, level: 1 }, 0.0)
3195
+ end
3196
+ tab_strs << colored_t
3197
+ raw_tab_strs << raw_t
3198
+ end
3199
+ raw_tabs_joined = raw_tab_strs.join(" ")
3200
+ tabs_w = GRmenu.display_width(raw_tabs_joined)
3201
+ l_tabs_pad = [(total_width - tabs_w) / 2, 0].max
3202
+ formatted_tabs = (" " * l_tabs_pad) + tab_strs.join(" ")
3203
+
3204
+ start_col = margin_left.length + l_tabs_pad
3205
+ cur_x = start_col
3206
+ @tabs.each_with_index do |_t_name, t_i|
3207
+ t_len = GRmenu.display_width(raw_tab_strs[t_i])
3208
+ @tab_ranges[t_i] = (cur_x + 1)..(cur_x + t_len)
3209
+ cur_x += t_len + 3
3210
+ end
3211
+
3212
+ @tabs_row = rendered_lines.length + 1
3213
+ rendered_lines << "#{margin_left}#{formatted_tabs}"
3214
+ rendered_lines << ""
3215
+ end
3216
+
2022
3217
  if box_border
2023
3218
  h_top = box_border[:ht] || box_border[:h]
2024
3219
  h_bot = box_border[:hb] || box_border[:h]
@@ -2055,12 +3250,14 @@ class GRmenu
2055
3250
  end
2056
3251
 
2057
3252
  if has_more_above
3253
+ @up_arrow_row = rendered_lines.length + 1
2058
3254
  up_text = "▲ (+#{start_row} #{cols > 1 ? 'filas' : 'arriba'})"
2059
3255
  pad_up = [avail_w - GRmenu.display_width(up_text), 0].max
2060
3256
  up_indicator = colorize(" " * (pad_up / 2) + up_text + " " * (pad_up - (pad_up / 2)), { color: "gray", level: 2 })
2061
3257
  rendered_lines << "#{margin_left}#{v_left} #{up_indicator} #{v_right}"
2062
3258
  end
2063
3259
 
3260
+ parent_row_idx = nil
2064
3261
  if rows_data.empty?
2065
3262
  no_res_txt = "(Sin resultados)"
2066
3263
  pad_no = [avail_w - GRmenu.display_width(no_res_txt), 0].max
@@ -2073,12 +3270,25 @@ class GRmenu
2073
3270
  item_idx = row_indices[c_idx]
2074
3271
  if item_idx
2075
3272
  op_name = all_names[item_idx]
3273
+ is_sub = is_submenu_item?(@functions[item_idx])
3274
+ arrow = is_sub ? "▶" : ""
2076
3275
  if @index == item_idx
2077
- cell_raw = "> #{op_name}"
3276
+ parent_row_idx = rendered_lines.length
3277
+ cell_raw = if is_sub
3278
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3279
+ "> #{op_name}#{' ' * pad_sub}#{arrow}"
3280
+ else
3281
+ "> #{op_name}"
3282
+ end
2078
3283
  pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2079
3284
  cells << colorize(cell_raw + (" " * pad_c), focus_color_cfg, r_idx * 0.3)
2080
3285
  else
2081
- cell_raw = " #{op_name}"
3286
+ cell_raw = if is_sub
3287
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3288
+ " #{op_name}#{' ' * pad_sub}#{arrow}"
3289
+ else
3290
+ " #{op_name}"
3291
+ end
2082
3292
  pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2083
3293
  cells << colorize(cell_raw + (" " * pad_c), options_color_cfg, r_idx * 0.2)
2084
3294
  end
@@ -2089,11 +3299,14 @@ class GRmenu
2089
3299
  row_str = cells.join(" ")
2090
3300
  pad_r = [avail_w - (col_w * cols + (cols - 1) * 2), 0].max
2091
3301
  row_padded = row_str + (" " * pad_r)
3302
+ cur_line_num = rendered_lines.length + 1
3303
+ @row_hit_map[cur_line_num] = { row_indices: row_indices, cols: cols, col_w: col_w, margin_left: margin_left.length }
2092
3304
  rendered_lines << "#{margin_left}#{v_left} #{row_padded} #{v_right}"
2093
3305
  end
2094
3306
  end
2095
3307
 
2096
3308
  if has_more_below
3309
+ @down_arrow_row = rendered_lines.length + 1
2097
3310
  remaining_below = total_rows - 1 - end_row
2098
3311
  down_text = "▼ (+#{remaining_below} #{cols > 1 ? 'filas' : 'abajo'})"
2099
3312
  pad_down = [avail_w - GRmenu.display_width(down_text), 0].max
@@ -2104,7 +3317,9 @@ class GRmenu
2104
3317
  unless active_desc.empty?
2105
3318
  separator_line = v_l_raw + mid_fill + v_r_raw
2106
3319
  rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 1.0)}"
2107
- raw_desc = "* #{active_desc}"
3320
+ pfx = (@style_config&.desc_prefix || @desc_prefix || SetStyle.desc_prefix || "[i]").to_s
3321
+ pfx = "#{pfx} " unless pfx.end_with?(" ")
3322
+ raw_desc = "#{pfx}#{active_desc}"
2108
3323
  pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
2109
3324
  desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 })
2110
3325
  rendered_lines << "#{margin_left}#{v_left} #{desc_text} #{v_right}"
@@ -2136,12 +3351,14 @@ class GRmenu
2136
3351
  end
2137
3352
 
2138
3353
  if has_more_above
3354
+ @up_arrow_row = rendered_lines.length + 1
2139
3355
  up_text = "▲ (+#{start_row} #{cols > 1 ? 'filas' : 'arriba'})"
2140
3356
  pad_up = [avail_w - GRmenu.display_width(up_text), 0].max
2141
3357
  up_indicator = colorize(" " * (pad_up / 2) + up_text + " " * (pad_up - (pad_up / 2)), { color: "gray", level: 2 })
2142
3358
  rendered_lines << "#{margin_left}#{solid_border} #{up_indicator} #{solid_border}"
2143
3359
  end
2144
3360
 
3361
+ parent_row_idx = nil
2145
3362
  if rows_data.empty?
2146
3363
  no_res_txt = "(Sin resultados)"
2147
3364
  pad_no = [avail_w - GRmenu.display_width(no_res_txt), 0].max
@@ -2154,12 +3371,25 @@ class GRmenu
2154
3371
  item_idx = row_indices[c_idx]
2155
3372
  if item_idx
2156
3373
  op_name = all_names[item_idx]
3374
+ is_sub = is_submenu_item?(@functions[item_idx])
3375
+ arrow = is_sub ? "▶" : ""
2157
3376
  if @index == item_idx
2158
- cell_raw = "> #{op_name}"
3377
+ parent_row_idx = rendered_lines.length
3378
+ cell_raw = if is_sub
3379
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3380
+ "> #{op_name}#{' ' * pad_sub}#{arrow}"
3381
+ else
3382
+ "> #{op_name}"
3383
+ end
2159
3384
  pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2160
3385
  cells << colorize(cell_raw + (" " * pad_c), focus_color_cfg, r_idx * 0.3)
2161
3386
  else
2162
- cell_raw = " #{op_name}"
3387
+ cell_raw = if is_sub
3388
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3389
+ " #{op_name}#{' ' * pad_sub}#{arrow}"
3390
+ else
3391
+ " #{op_name}"
3392
+ end
2163
3393
  pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2164
3394
  cells << colorize(cell_raw + (" " * pad_c), options_color_cfg, r_idx * 0.2)
2165
3395
  end
@@ -2170,11 +3400,14 @@ class GRmenu
2170
3400
  row_str = cells.join(" ")
2171
3401
  pad_r = [avail_w - (col_w * cols + (cols - 1) * 2), 0].max
2172
3402
  row_padded = row_str + (" " * pad_r)
3403
+ cur_line_num = rendered_lines.length + 1
3404
+ @row_hit_map[cur_line_num] = { row_indices: row_indices, cols: cols, col_w: col_w, margin_left: margin_left.length }
2173
3405
  rendered_lines << "#{margin_left}#{solid_border} #{row_padded} #{solid_border}"
2174
3406
  end
2175
3407
  end
2176
3408
 
2177
3409
  if has_more_below
3410
+ @down_arrow_row = rendered_lines.length + 1
2178
3411
  remaining_below = total_rows - 1 - end_row
2179
3412
  down_text = "▼ (+#{remaining_below} #{cols > 1 ? 'filas' : 'abajo'})"
2180
3413
  pad_down = [avail_w - GRmenu.display_width(down_text), 0].max
@@ -2184,7 +3417,9 @@ class GRmenu
2184
3417
 
2185
3418
  unless active_desc.empty?
2186
3419
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
2187
- raw_desc = "* #{active_desc}"
3420
+ pfx = (@style_config&.desc_prefix || @desc_prefix || SetStyle.desc_prefix || "[i]").to_s
3421
+ pfx = "#{pfx} " unless pfx.end_with?(" ")
3422
+ raw_desc = "#{pfx}#{active_desc}"
2188
3423
  pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
2189
3424
  desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 })
2190
3425
  rendered_lines << "#{margin_left}#{solid_border} #{desc_text} #{solid_border}"
@@ -2193,6 +3428,152 @@ class GRmenu
2193
3428
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
2194
3429
  end
2195
3430
 
3431
+ if @open_level >= 1 && is_submenu_item?(@functions[@index]) && parent_row_idx
3432
+ sub_1_acts = get_submenu_actions(@functions[@index])
3433
+ if sub_1_acts && !sub_1_acts.empty?
3434
+ sub_1_title = all_names[@index] || "Submenu"
3435
+ sub_1_lines, sub_1_w, p_row_1 = render_submenu_box(sub_1_acts, sub_1_title, is_active: (@level == 1), selected_idx: @sub_index_1)
3436
+
3437
+ sub_2_acts = nil
3438
+ sub_2_lines = nil
3439
+ sub_2_w = 0
3440
+ p_row_2 = nil
3441
+ if @open_level >= 2 && is_submenu_item?(sub_1_acts[@sub_index_1])
3442
+ sub_2_acts = get_submenu_actions(sub_1_acts[@sub_index_1])
3443
+ if sub_2_acts && !sub_2_acts.empty?
3444
+ sub_2_title = extract_name_from_action(sub_1_acts[@sub_index_1]) || "Submenu"
3445
+ sub_2_lines, sub_2_w, p_row_2 = render_submenu_box(sub_2_acts, sub_2_title, is_active: (@level == 2), selected_idx: @sub_index_2)
3446
+ end
3447
+ end
3448
+
3449
+ if sub_2_lines
3450
+ total_3_w = margin_left.length + total_width + 2 + sub_1_w + 2 + sub_2_w
3451
+ if total_3_w <= term_cols
3452
+ sub_1_start = [[parent_row_idx - 1, 0].max, [rendered_lines.length - sub_1_lines.length, 0].max].min
3453
+ p1_abs_row = sub_1_start + (p_row_1 || 1)
3454
+ sub_2_start = [[p1_abs_row - 1, 0].max, [rendered_lines.length - sub_2_lines.length, 0].max].min
3455
+ max_3_lines = [rendered_lines.length, sub_1_start + sub_1_lines.length, sub_2_start + sub_2_lines.length].max
3456
+
3457
+ x1_start = margin_left.length + total_width + 2
3458
+ x1_end = x1_start + sub_1_w
3459
+ x2_start = x1_end + 2
3460
+ x2_end = x2_start + sub_2_w
3461
+ @sub_1_col_rng = (x1_start..x1_end)
3462
+ @sub_2_col_rng = (x2_start..x2_end)
3463
+
3464
+ sub_1_offset = sub_1_title.empty? ? 1 : 3
3465
+ sub_2_offset = sub_2_title.empty? ? 1 : 3
3466
+
3467
+ stitched_lines = []
3468
+ max_3_lines.times do |i|
3469
+ l0 = rendered_lines[i] || ("#{margin_left}#{' ' * total_width}")
3470
+
3471
+ l1 = " " * sub_1_w
3472
+ br1 = " "
3473
+ if i >= sub_1_start && i < (sub_1_start + sub_1_lines.length)
3474
+ s1_idx = i - sub_1_start
3475
+ l1 = sub_1_lines[s1_idx]
3476
+ br1 = (i == parent_row_idx) ? "──" : " "
3477
+ s1_item = s1_idx - sub_1_offset
3478
+ if s1_item >= 0 && s1_item < sub_1_acts.length
3479
+ @sub_1_hit_map[i + 1] = s1_item
3480
+ @sub_hit_map[i + 1] = s1_item
3481
+ end
3482
+ end
3483
+
3484
+ l2 = ""
3485
+ br2 = ""
3486
+ if i >= sub_2_start && i < (sub_2_start + sub_2_lines.length)
3487
+ s2_idx = i - sub_2_start
3488
+ l2 = sub_2_lines[s2_idx]
3489
+ br2 = (i == p1_abs_row) ? "──" : " "
3490
+ s2_item = s2_idx - sub_2_offset
3491
+ if s2_item >= 0 && s2_item < sub_2_acts.length
3492
+ @sub_2_hit_map[i + 1] = s2_item
3493
+ end
3494
+ end
3495
+
3496
+ stitched_lines << "#{l0}#{br1}#{l1}#{br2}#{l2}".rstrip
3497
+ end
3498
+ rendered_lines = stitched_lines
3499
+ elsif (sub_1_w + 2 + sub_2_w) <= term_cols
3500
+ p1_abs_row = (p_row_1 || 1)
3501
+ sub_2_start = [[p1_abs_row - 1, 0].max, [sub_1_lines.length - sub_2_lines.length, 0].max].min
3502
+ max_2_lines = [sub_1_lines.length, sub_2_start + sub_2_lines.length].max
3503
+
3504
+ x1_start = margin_left.length
3505
+ x1_end = x1_start + sub_1_w
3506
+ x2_start = x1_end + 2
3507
+ x2_end = x2_start + sub_2_w
3508
+ @sub_1_col_rng = (x1_start..x1_end)
3509
+ @sub_2_col_rng = (x2_start..x2_end)
3510
+
3511
+ sub_1_offset = sub_1_title.empty? ? 1 : 3
3512
+ sub_2_offset = sub_2_title.empty? ? 1 : 3
3513
+
3514
+ stitched_lines = []
3515
+ max_2_lines.times do |i|
3516
+ l1 = sub_1_lines[i] || (" " * sub_1_w)
3517
+ l2 = ""
3518
+ br2 = ""
3519
+ if i >= sub_2_start && i < (sub_2_start + sub_2_lines.length)
3520
+ s2_idx = i - sub_2_start
3521
+ l2 = sub_2_lines[s2_idx]
3522
+ br2 = (i == p1_abs_row) ? "──" : " "
3523
+ s2_item = s2_idx - sub_2_offset
3524
+ @sub_2_hit_map[i + 1] = s2_item if s2_item >= 0 && s2_item < sub_2_acts.length
3525
+ end
3526
+ s1_item = i - sub_1_offset
3527
+ if s1_item >= 0 && s1_item < sub_1_acts.length
3528
+ @sub_1_hit_map[i + 1] = s1_item
3529
+ @sub_hit_map[i + 1] = s1_item
3530
+ end
3531
+ stitched_lines << "#{margin_left}#{l1}#{br2}#{l2}".rstrip
3532
+ end
3533
+ rendered_lines = stitched_lines
3534
+ else
3535
+ active_box = (@level == 2) ? sub_2_lines : sub_1_lines
3536
+ rendered_lines = active_box.map { |l| "#{margin_left}#{l}" }
3537
+ end
3538
+ else
3539
+ total_2_w = margin_left.length + total_width + 2 + sub_1_w
3540
+ if total_2_w <= term_cols
3541
+ sub_start_line = [[parent_row_idx - 1, 0].max, [rendered_lines.length - sub_1_lines.length, 0].max].min
3542
+ max_total_lines = [rendered_lines.length, sub_start_line + sub_1_lines.length].max
3543
+
3544
+ x1_start = margin_left.length + total_width + 2
3545
+ x1_end = x1_start + sub_1_w
3546
+ @sub_1_col_rng = (x1_start..x1_end)
3547
+
3548
+ sub_item_offset = sub_1_title.empty? ? 1 : 3
3549
+
3550
+ stitched_lines = []
3551
+ max_total_lines.times do |i|
3552
+ main_line = rendered_lines[i] || ("#{margin_left}#{' ' * total_width}")
3553
+ if i >= sub_start_line && i < (sub_start_line + sub_1_lines.length)
3554
+ sub_idx = i - sub_start_line
3555
+ sub_l = sub_1_lines[sub_idx]
3556
+ bridge = (i == parent_row_idx) ? "──" : " "
3557
+ stitched_lines << "#{main_line}#{bridge}#{sub_l}"
3558
+
3559
+ s_item_idx = sub_idx - sub_item_offset
3560
+ if s_item_idx >= 0 && s_item_idx < sub_1_acts.length
3561
+ @sub_1_hit_map[i + 1] = s_item_idx
3562
+ @sub_hit_map[i + 1] = s_item_idx
3563
+ end
3564
+ else
3565
+ stitched_lines << main_line
3566
+ end
3567
+ end
3568
+ rendered_lines = stitched_lines
3569
+ else
3570
+ active_box = (@level == 1) ? sub_1_lines : rendered_lines
3571
+ rendered_lines = active_box.map { |l| "#{margin_left}#{l}" }
3572
+ end
3573
+ end
3574
+ end
3575
+ end
3576
+
2196
3577
  rendered_lines
2197
3578
  end
2198
3579
 
@@ -2216,14 +3597,197 @@ class GRmenu
2216
3597
  end
2217
3598
  end
2218
3599
 
3600
+ def has_active_animation?
3601
+ return true if @animate && ["diagonal", "linear", "fade", "rgb", "rainbow", "chroma", "neon"].include?(@animate.to_s.downcase)
3602
+ return true if has_rgb_animation?
3603
+ return true if @active_tab_color && @active_tab_color.to_s.downcase.start_with?("neon")
3604
+ configs = [
3605
+ @style_config.border,
3606
+ @style_config.options,
3607
+ @style_config.focus,
3608
+ @style_config.title,
3609
+ @style_config.banner,
3610
+ @style_config.subtitle,
3611
+ @style_config.divider
3612
+ ]
3613
+ configs.any? do |c|
3614
+ if c.is_a?(Hash)
3615
+ val = (c[:color] || c["color"]).to_s.downcase.strip
3616
+ val.start_with?("neon")
3617
+ else
3618
+ false
3619
+ end
3620
+ end
3621
+ end
3622
+
3623
+ def style(css_content)
3624
+ parsed = self.class.parse_config_text(css_content)
3625
+ m = ((parsed[:sections] && parsed[:sections]["menu"]) || {}).merge(parsed[:global] || {})
3626
+ if m["style"]
3627
+ @style = m["style"].to_i
3628
+ @border_config = BORDERS[@style] || BORDERS[3]
3629
+ end
3630
+ @banner_style = m["banner_style"].to_i if m["banner_style"]
3631
+ @animate = m["animate"].to_s if m["animate"]
3632
+ @center = (m["center"].to_s != "false") if m.key?("center")
3633
+ if m["border"] || m["border_color"]
3634
+ c, l = self.class.extract_color_and_level(m["border"] || m["border_color"], 1)
3635
+ @style_config.border(c, l)
3636
+ end
3637
+ if m["options"] || m["options_color"]
3638
+ c, l = self.class.extract_color_and_level(m["options"] || m["options_color"], 1)
3639
+ @style_config.options(c, l)
3640
+ end
3641
+ if m["focus"] || m["focus_color"]
3642
+ c, l = self.class.extract_color_and_level(m["focus"] || m["focus_color"], 2)
3643
+ @style_config.focus(c, l)
3644
+ end
3645
+ if m["title"] || m["title_color"]
3646
+ c, l = self.class.extract_color_and_level(m["title"] || m["title_color"], 2)
3647
+ @style_config.title(c, l)
3648
+ end
3649
+ if m["banner"] || m["banner_color"]
3650
+ c, l = self.class.extract_color_and_level(m["banner"] || m["banner_color"], 2)
3651
+ @style_config.banner(c, l)
3652
+ end
3653
+ if m["subtitle"] || m["subtitle_color"]
3654
+ c, l = self.class.extract_color_and_level(m["subtitle"] || m["subtitle_color"], 1)
3655
+ @style_config.subtitle(c, l)
3656
+ end
3657
+ if m["divider"] || m["divider_color"]
3658
+ c, l = self.class.extract_color_and_level(m["divider"] || m["divider_color"], 1)
3659
+ @style_config.divider(c, l)
3660
+ end
3661
+ if m["desc_prefix"] || m["description_prefix"] || m["prefix"]
3662
+ @style_config.desc_prefix(m["desc_prefix"] || m["description_prefix"] || m["prefix"])
3663
+ end
3664
+ @style_config.font(m["font"].to_i) if m["font"]
3665
+ @mouse = (m["mouse"].to_s == "true") if m.key?("mouse")
3666
+ if parsed[:sections] && parsed[:sections]["tabs"]
3667
+ t_sec = parsed[:sections]["tabs"]
3668
+ @active_tab_color = t_sec["active_tab"] || t_sec["active_tab_color"] || @active_tab_color if (t_sec["active_tab"] || t_sec["active_tab_color"])
3669
+ @tab_color = t_sec["tab_color"] || t_sec["inactive_tab"] || t_sec["color"] || @tab_color if (t_sec["tab_color"] || t_sec["inactive_tab"] || t_sec["color"])
3670
+ end
3671
+ self
3672
+ end
3673
+
3674
+ def export_config(path = nil)
3675
+ if path.nil?
3676
+ caller_loc = caller_locations.find { |c| !c.path.include?(__FILE__) }
3677
+ base = caller_loc ? caller_loc.path.sub(/\.rb$/, '') : "theme"
3678
+ path = "#{base}.gr"
3679
+ end
3680
+ b_cfg = @style_config&.border || SetStyle.border
3681
+ t_cfg = @style_config&.title || SetStyle.title
3682
+ f_cfg = @style_config&.focus || SetStyle.focus
3683
+ o_cfg = @style_config&.options || SetStyle.options
3684
+ bn_cfg = @style_config&.banner || SetStyle.banner
3685
+ s_cfg = @style_config&.subtitle || SetStyle.subtitle
3686
+ d_cfg = @style_config&.divider || SetStyle.divider
3687
+ dp_val = @style_config&.desc_prefix || SetStyle.desc_prefix
3688
+
3689
+ lines = ["GRmenu::config<-1->", ""]
3690
+ lines << "@theme:: \"#{File.basename(path, '.gr').capitalize}\""
3691
+ lines << "@author:: \"grcode\""
3692
+ lines << "@version:: \"1.0\""
3693
+ lines << ""
3694
+ lines << "<<menu"
3695
+ lines << " style:: #{@style || 3}"
3696
+ lines << " banner_style:: #{@banner_style || 3}"
3697
+ lines << " font:: #{@style_config&.font || SetStyle.font}"
3698
+ lines << " animate:: #{@animate || 'rgb'}"
3699
+ lines << " center:: #{@center.nil? ? true : @center}"
3700
+ lines << " desc_prefix:: #{dp_val}"
3701
+ lines << " mouse:: #{@mouse}" if @mouse
3702
+ lines << " border:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3703
+ lines << " title:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3704
+ lines << " focus:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3705
+ lines << " options:: #{o_cfg[:color]}:#{o_cfg[:level]}"
3706
+ lines << " banner:: #{bn_cfg[:color]}:#{bn_cfg[:level]}"
3707
+ lines << " subtitle:: #{s_cfg[:color]}:#{s_cfg[:level]}"
3708
+ lines << " divider:: #{d_cfg[:color]}:#{d_cfg[:level]}"
3709
+ lines << ">>"
3710
+ lines << ""
3711
+ lines << "<<submenu"
3712
+ lines << " style:: #{@style || 3}"
3713
+ lines << " border:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3714
+ lines << " focus:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3715
+ lines << " options:: #{o_cfg[:color]}:#{o_cfg[:level]}"
3716
+ lines << ">>"
3717
+ lines << ""
3718
+ lines << "<<tabs"
3719
+ lines << " active_tab:: #{@active_tab_color || 'yellow'}:2"
3720
+ lines << " tab_color:: #{@tab_color || 'gray'}:1"
3721
+ lines << ">>"
3722
+ lines << ""
3723
+ lines << "<<input"
3724
+ lines << " style:: 3"
3725
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3726
+ lines << " title_color:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3727
+ lines << " label_color:: white:1"
3728
+ lines << ">>"
3729
+ lines << ""
3730
+ lines << "<<table"
3731
+ lines << " style:: #{@style || 3}"
3732
+ lines << " header_color:: yellow:2"
3733
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3734
+ lines << " selected_row:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3735
+ lines << " row_color:: white:1"
3736
+ lines << " zebra_striping:: true"
3737
+ lines << ">>"
3738
+ lines << ""
3739
+ lines << "<<card"
3740
+ lines << " style:: 7"
3741
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3742
+ lines << " title_color:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3743
+ lines << " content_color:: white:1"
3744
+ lines << ">>"
3745
+ lines << ""
3746
+ lines << "<<slider"
3747
+ lines << " style:: #{@style || 3}"
3748
+ lines << " color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3749
+ lines << " fill_char:: █"
3750
+ lines << " empty_char:: ░"
3751
+ lines << ">>"
3752
+ lines << ""
3753
+ lines << "<<checkbox"
3754
+ lines << " style:: #{@style || 3}"
3755
+ lines << " color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3756
+ lines << " checked_mark:: [X]"
3757
+ lines << " unchecked_mark:: [ ]"
3758
+ lines << ">>"
3759
+ lines << ""
3760
+ File.write(path, lines.join("\n") + "\n")
3761
+ path
3762
+ end
3763
+ alias_method :export_theme, :export_config
3764
+
2219
3765
  def draw(size_max: 20, min_width: nil)
3766
+ if ARGV.any? { |a| ["-theme", "--theme", "-ex", "--export-theme"].include?(a.to_s.downcase) }
3767
+ out_idx = ARGV.index { |a| ["-o", "--out", "--output"].include?(a.to_s.downcase) }
3768
+ target_file = out_idx ? ARGV[out_idx + 1] : "tema_exportado.gr"
3769
+ export_config(target_file)
3770
+ Kernel.puts Color.bright_green("[OK] Tema exportado exitosamente a: #{target_file}")
3771
+ exit(0)
3772
+ end
3773
+
2220
3774
  target_width = min_width || size_max || 20
2221
3775
  action_to_execute = nil
2222
3776
 
3777
+ self.class.enable_windows_vt
3778
+ $stdout.sync = true
3779
+
2223
3780
  is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
2224
3781
 
2225
3782
  begin
2226
3783
  Kernel.print("#{HIDE_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
3784
+ Kernel.print(ENABLE_MOUSE) if @mouse
3785
+ $stdout.flush
3786
+
3787
+ if @animate && !["false", "rgb", "", "nil"].include?(@animate.downcase)
3788
+ intro_lines = render_lines(target_width)
3789
+ self.class.animate_render(intro_lines, @animate)
3790
+ end
2227
3791
 
2228
3792
  if is_tty
2229
3793
  $stdin.raw do |raw_input_stream|
@@ -2233,17 +3797,23 @@ class GRmenu
2233
3797
  action_to_execute = run_interactive_loop($stdin, target_width)
2234
3798
  end
2235
3799
  ensure
3800
+ Kernel.print(DISABLE_MOUSE) if @mouse
2236
3801
  Kernel.print(SHOW_CURSOR)
3802
+ $stdout.flush
2237
3803
  end
2238
3804
 
2239
3805
  if action_to_execute
2240
3806
  Kernel.print(CLEAR_SCREEN_SEQUENCE)
3807
+ $stdout.flush
2241
3808
  execute_action(action_to_execute)
2242
3809
  else
2243
3810
  Kernel.print(CLEAR_SCREEN_SEQUENCE)
3811
+ $stdout.flush
2244
3812
  end
2245
3813
  rescue Interrupt
3814
+ Kernel.print(DISABLE_MOUSE) if @mouse
2246
3815
  Kernel.print("#{SHOW_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
3816
+ $stdout.flush
2247
3817
  nil
2248
3818
  end
2249
3819
 
@@ -2258,6 +3828,7 @@ class GRmenu
2258
3828
  end
2259
3829
  buffer << CLEAR_TO_EOS
2260
3830
  Kernel.print(buffer)
3831
+ $stdout.flush
2261
3832
  end
2262
3833
 
2263
3834
  def run_interactive_loop(input_stream, target_width)
@@ -2266,7 +3837,7 @@ class GRmenu
2266
3837
  @rgb_tick = 0.0
2267
3838
  draw_frame(target_width)
2268
3839
 
2269
- animating = has_rgb_animation?
3840
+ animating = has_active_animation?
2270
3841
 
2271
3842
  while true
2272
3843
  if animating
@@ -2292,6 +3863,228 @@ class GRmenu
2292
3863
  key = read_single_key(input_stream)
2293
3864
  break if key.nil? || key == "\x03" || key == "\x04"
2294
3865
 
3866
+ if key =~ /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
3867
+ btn = $1.to_i
3868
+ col = $2.to_i
3869
+ row = $3.to_i
3870
+ act = $4
3871
+
3872
+ if btn == 64
3873
+ if @open_level >= 2 && @sub_2_col_rng && @sub_2_col_rng.cover?(col) && is_submenu_item?(@functions[@index])
3874
+ s1_acts = get_submenu_actions(@functions[@index])
3875
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
3876
+ @sub_index_2 = (@sub_index_2 - 1) % s2_acts.length if s2_acts && !s2_acts.empty?
3877
+ @level = 2
3878
+ elsif @open_level >= 1 && @sub_1_col_rng && @sub_1_col_rng.cover?(col) && is_submenu_item?(@functions[@index])
3879
+ s1_acts = get_submenu_actions(@functions[@index])
3880
+ @sub_index_1 = (@sub_index_1 - 1) % s1_acts.length if s1_acts && !s1_acts.empty?
3881
+ @sub_index_2 = 0
3882
+ @level = 1
3883
+ else
3884
+ move_up
3885
+ @sub_index_1 = 0
3886
+ @sub_index_2 = 0
3887
+ @level = 0
3888
+ end
3889
+ draw_frame(target_width)
3890
+ next
3891
+ elsif btn == 65
3892
+ if @open_level >= 2 && @sub_2_col_rng && @sub_2_col_rng.cover?(col) && is_submenu_item?(@functions[@index])
3893
+ s1_acts = get_submenu_actions(@functions[@index])
3894
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
3895
+ @sub_index_2 = (@sub_index_2 + 1) % s2_acts.length if s2_acts && !s2_acts.empty?
3896
+ @level = 2
3897
+ elsif @open_level >= 1 && @sub_1_col_rng && @sub_1_col_rng.cover?(col) && is_submenu_item?(@functions[@index])
3898
+ s1_acts = get_submenu_actions(@functions[@index])
3899
+ @sub_index_1 = (@sub_index_1 + 1) % s1_acts.length if s1_acts && !s1_acts.empty?
3900
+ @sub_index_2 = 0
3901
+ @level = 1
3902
+ else
3903
+ move_down
3904
+ @sub_index_1 = 0
3905
+ @sub_index_2 = 0
3906
+ @level = 0
3907
+ end
3908
+ draw_frame(target_width)
3909
+ next
3910
+ elsif btn == 0 && act == "M"
3911
+ if @tabs && !@tabs.empty? && @tabs_row && row == @tabs_row
3912
+ clicked_tab = @tab_ranges.find { |_idx, rng| rng.cover?(col) }
3913
+ if clicked_tab
3914
+ @active_tab_idx = clicked_tab[0]
3915
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
3916
+ @index = 0
3917
+ @level = 0
3918
+ @open_level = 0
3919
+ @sub_index_1 = 0
3920
+ @sub_index_2 = 0
3921
+ @active_panel = :main
3922
+ @submenu_open = false
3923
+ draw_frame(target_width)
3924
+ next
3925
+ end
3926
+ end
3927
+
3928
+ if @up_arrow_row && row == @up_arrow_row
3929
+ move_up
3930
+ draw_frame(target_width)
3931
+ next
3932
+ end
3933
+
3934
+ if @down_arrow_row && row == @down_arrow_row
3935
+ move_down
3936
+ draw_frame(target_width)
3937
+ next
3938
+ end
3939
+
3940
+ if @open_level >= 2 && @sub_2_col_rng && @sub_2_col_rng.cover?(col) && @sub_2_hit_map[row]
3941
+ s2_clicked = @sub_2_hit_map[row]
3942
+ s1_acts = get_submenu_actions(@functions[@index])
3943
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
3944
+ if s2_acts && s2_clicked < s2_acts.length
3945
+ @level = 2
3946
+ @sub_index_2 = s2_clicked
3947
+ return s2_acts[s2_clicked]
3948
+ end
3949
+ end
3950
+
3951
+ if @open_level >= 1 && @sub_1_col_rng && @sub_1_col_rng.cover?(col) && @sub_1_hit_map[row]
3952
+ s1_clicked = @sub_1_hit_map[row]
3953
+ s1_acts = get_submenu_actions(@functions[@index])
3954
+ if s1_acts && s1_clicked < s1_acts.length
3955
+ t_act = s1_acts[s1_clicked]
3956
+ @level = 1
3957
+ @sub_index_1 = s1_clicked
3958
+ if is_submenu_item?(t_act)
3959
+ @open_level = 2
3960
+ @level = 2
3961
+ @sub_index_2 = 0
3962
+ draw_frame(target_width)
3963
+ next
3964
+ else
3965
+ return t_act
3966
+ end
3967
+ end
3968
+ end
3969
+
3970
+ if @row_hit_map && @row_hit_map[row]
3971
+ hit = @row_hit_map[row]
3972
+ x_in_box = col - hit[:margin_left]
3973
+ if x_in_box > 0 && (hit[:total_w].nil? || x_in_box <= hit[:total_w])
3974
+ c_idx = [[((x_in_box - 2) / ([hit[:col_w], 1].max + 2)).to_i, 0].max, hit[:cols] - 1].min
3975
+ clicked_item = hit[:row_indices][c_idx]
3976
+ if clicked_item
3977
+ @index = clicked_item
3978
+ if is_submenu_item?(@functions[clicked_item])
3979
+ @open_level = 1
3980
+ @level = 1
3981
+ @sub_index_1 = 0
3982
+ @sub_index_2 = 0
3983
+ @active_panel = :sub
3984
+ @submenu_open = true
3985
+ draw_frame(target_width)
3986
+ next
3987
+ else
3988
+ return @functions[clicked_item]
3989
+ end
3990
+ end
3991
+ end
3992
+ end
3993
+ end
3994
+ next
3995
+ end
3996
+
3997
+ if @tabs && !@tabs.empty?
3998
+ if key == "\t"
3999
+ @active_tab_idx = (@active_tab_idx + 1) % @tabs.length
4000
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
4001
+ @index = 0
4002
+ @level = 0
4003
+ @open_level = 0
4004
+ @sub_index_1 = 0
4005
+ @sub_index_2 = 0
4006
+ @active_panel = :main
4007
+ @submenu_open = false
4008
+ draw_frame(target_width)
4009
+ next
4010
+ elsif key == "\e[Z"
4011
+ @active_tab_idx = (@active_tab_idx - 1) % @tabs.length
4012
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
4013
+ @index = 0
4014
+ @level = 0
4015
+ @open_level = 0
4016
+ @sub_index_1 = 0
4017
+ @sub_index_2 = 0
4018
+ @active_panel = :main
4019
+ @submenu_open = false
4020
+ draw_frame(target_width)
4021
+ next
4022
+ end
4023
+ end
4024
+
4025
+ if @level == 2 && @open_level >= 2 && is_submenu_item?(@functions[@index])
4026
+ s1_acts = get_submenu_actions(@functions[@index])
4027
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
4028
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
4029
+ @sub_index_2 = (@sub_index_2 - 1) % s2_acts.length if s2_acts && !s2_acts.empty?
4030
+ draw_frame(target_width)
4031
+ next
4032
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
4033
+ @sub_index_2 = (@sub_index_2 + 1) % s2_acts.length if s2_acts && !s2_acts.empty?
4034
+ draw_frame(target_width)
4035
+ next
4036
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "\e"
4037
+ @open_level = 1
4038
+ @level = 1
4039
+ draw_frame(target_width)
4040
+ next
4041
+ elsif key == "\r" || key == "\n"
4042
+ return s2_acts[@sub_index_2] if s2_acts && @sub_index_2 < s2_acts.length
4043
+ end
4044
+ end
4045
+
4046
+ if @level == 1 && @open_level >= 1 && is_submenu_item?(@functions[@index])
4047
+ s1_acts = get_submenu_actions(@functions[@index])
4048
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
4049
+ @sub_index_1 = (@sub_index_1 - 1) % s1_acts.length if s1_acts && !s1_acts.empty?
4050
+ @sub_index_2 = 0
4051
+ draw_frame(target_width)
4052
+ next
4053
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
4054
+ @sub_index_1 = (@sub_index_1 + 1) % s1_acts.length if s1_acts && !s1_acts.empty?
4055
+ @sub_index_2 = 0
4056
+ draw_frame(target_width)
4057
+ next
4058
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "\e"
4059
+ @open_level = 0
4060
+ @level = 0
4061
+ @active_panel = :main
4062
+ @submenu_open = false
4063
+ draw_frame(target_width)
4064
+ next
4065
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M"
4066
+ cur_l1 = s1_acts[@sub_index_1] if s1_acts
4067
+ if is_submenu_item?(cur_l1)
4068
+ @open_level = 2
4069
+ @level = 2
4070
+ @sub_index_2 = 0
4071
+ draw_frame(target_width)
4072
+ next
4073
+ end
4074
+ elsif key == "\r" || key == "\n"
4075
+ cur_l1 = s1_acts[@sub_index_1] if s1_acts
4076
+ if is_submenu_item?(cur_l1)
4077
+ @open_level = 2
4078
+ @level = 2
4079
+ @sub_index_2 = 0
4080
+ draw_frame(target_width)
4081
+ next
4082
+ else
4083
+ return cur_l1
4084
+ end
4085
+ end
4086
+ end
4087
+
2295
4088
  if !@search && (key == "q" || key == "Q")
2296
4089
  break
2297
4090
  end
@@ -2307,16 +4100,38 @@ class GRmenu
2307
4100
  end
2308
4101
  elsif key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
2309
4102
  move_up
4103
+ @sub_index_1 = 0
4104
+ @sub_index_2 = 0
2310
4105
  draw_frame(target_width)
2311
4106
  elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
2312
4107
  move_down
4108
+ @sub_index_1 = 0
4109
+ @sub_index_2 = 0
2313
4110
  draw_frame(target_width)
2314
4111
  elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K"
2315
- move_left
2316
- draw_frame(target_width)
4112
+ if @open_level > 0
4113
+ @open_level = 0
4114
+ @level = 0
4115
+ @active_panel = :main
4116
+ @submenu_open = false
4117
+ draw_frame(target_width)
4118
+ else
4119
+ move_left
4120
+ draw_frame(target_width)
4121
+ end
2317
4122
  elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M"
2318
- move_right
2319
- draw_frame(target_width)
4123
+ if is_submenu_item?(@functions[@index])
4124
+ @open_level = 1
4125
+ @level = 1
4126
+ @sub_index_1 = 0
4127
+ @sub_index_2 = 0
4128
+ @active_panel = :sub
4129
+ @submenu_open = true
4130
+ draw_frame(target_width)
4131
+ else
4132
+ move_right
4133
+ draw_frame(target_width)
4134
+ end
2320
4135
  elsif key == "\x7f" || key == "\b" || key == "\x08"
2321
4136
  if @search && !@query.empty?
2322
4137
  @query.chop!
@@ -2333,7 +4148,19 @@ class GRmenu
2333
4148
  end
2334
4149
  elsif key == "\r" || key == "\n"
2335
4150
  matching = current_matching_indices
2336
- return @functions[@index] if matching.include?(@index)
4151
+ if matching.include?(@index)
4152
+ if is_submenu_item?(@functions[@index])
4153
+ @open_level = 1
4154
+ @level = 1
4155
+ @sub_index_1 = 0
4156
+ @sub_index_2 = 0
4157
+ @active_panel = :sub
4158
+ @submenu_open = true
4159
+ draw_frame(target_width)
4160
+ else
4161
+ return @functions[@index]
4162
+ end
4163
+ end
2337
4164
  elsif @search && key =~ /^[[:print:]]$/
2338
4165
  @query << key
2339
4166
  matching = current_matching_indices