grmenu 3.0.0 → 4.0.1

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.1"
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[?1006h"
17
+ DISABLE_MOUSE = "\e[?1000l\e[?1006l"
15
18
 
16
19
  def self.find_data_file(filename)
17
20
  local_path = File.expand_path("data/#{filename}", __dir__)
@@ -30,9 +33,37 @@ class GRmenu
30
33
  end
31
34
 
32
35
  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
36
+ 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
37
  FONTS = load_json_data('fonts.json').transform_keys(&:to_i).freeze
35
38
 
39
+ BASE_RGB = COLORS.each_with_object({}) do |(name, val), h|
40
+ next if name == "reset"
41
+ code = val.is_a?(Hash) ? (val["2"] || val[2] || val["1"] || val[1]) : val.to_s
42
+ if code =~ /38;2;(\d+);(\d+);(\d+)/
43
+ h[name] = [$1.to_i, $2.to_i, $3.to_i]
44
+ elsif code == "90m" || code == "30m"
45
+ h[name] = [100, 100, 100]
46
+ elsif code == "91m" || code == "31m"
47
+ h[name] = [255, 60, 60]
48
+ elsif code == "92m" || code == "32m"
49
+ h[name] = [60, 255, 60]
50
+ elsif code == "93m" || code == "33m"
51
+ h[name] = [255, 255, 60]
52
+ elsif code == "94m" || code == "34m"
53
+ h[name] = [60, 120, 255]
54
+ elsif code == "95m" || code == "35m"
55
+ h[name] = [255, 60, 255]
56
+ elsif code == "96m" || code == "36m"
57
+ h[name] = [60, 255, 255]
58
+ elsif code == "97m" || code == "37m"
59
+ h[name] = [250, 250, 250]
60
+ elsif code =~ /38;5;(\d+)/
61
+ h[name] = [150, 150, 150]
62
+ else
63
+ h[name] = [220, 220, 220]
64
+ end
65
+ end.freeze
66
+
36
67
  FONT_1 = FONTS[1] || {}
37
68
  FONT_2 = FONTS[2] || {}
38
69
  FONT_3 = FONTS[3] || {}
@@ -53,8 +84,26 @@ class GRmenu
53
84
  end
54
85
 
55
86
  def self.ansi_color(color_name, level = 1)
56
- name = color_name.to_s.downcase
87
+ name = color_name.to_s.downcase.strip
88
+ if name.include?(":")
89
+ parts = name.split(":")
90
+ name = parts[0].strip
91
+ level = parts[1].to_i if parts[1] && !parts[1].empty?
92
+ end
57
93
  return rgb_color(0.0) if name == "rgb" || name == "rainbow" || name == "chroma"
94
+ if name =~ /\A#?([0-9a-f]{6})\z/i
95
+ hex = $1
96
+ r = hex[0..1].to_i(16)
97
+ g = hex[2..3].to_i(16)
98
+ b = hex[4..5].to_i(16)
99
+ return "\e[38;2;#{r};#{g};#{b}m"
100
+ elsif name =~ /\A#?([0-9a-f]{3})\z/i
101
+ hex = $1
102
+ r = (hex[0] * 2).to_i(16)
103
+ g = (hex[1] * 2).to_i(16)
104
+ b = (hex[2] * 2).to_i(16)
105
+ return "\e[38;2;#{r};#{g};#{b}m"
106
+ end
58
107
  lvl_str = level.to_s
59
108
  code_raw = COLORS.dig(name, lvl_str) || COLORS.dig(name, level.to_i) || COLORS[name]
60
109
  return "\e[#{code_raw}" if code_raw
@@ -68,24 +117,6 @@ class GRmenu
68
117
  module Color
69
118
  RESET = "\e[0m"
70
119
  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
120
  module_function
90
121
 
91
122
  def paint(text, color_name, level = 1)
@@ -93,7 +124,7 @@ class GRmenu
93
124
  if c_str == "rgb" || c_str == "rainbow" || c_str == "chroma"
94
125
  return rgb(text)
95
126
  end
96
- code = CODES.dig(color_name.to_sym, level) || "\e[37m"
127
+ code = GRmenu.ansi_color(color_name, level) || "\e[37m"
97
128
  "#{code}#{text}#{RESET}"
98
129
  end
99
130
 
@@ -174,6 +205,19 @@ class GRmenu
174
205
  def bright_gray(s); paint(s, :gray, 2); end
175
206
  def grey(s); gray(s); end
176
207
 
208
+ def neon_red(s); paint(s, :neon_red, 2); end
209
+ def neon_green(s); paint(s, :neon_green, 2); end
210
+ def neon_cyan(s); paint(s, :neon_cyan, 2); end
211
+ def neon_blue(s); paint(s, :neon_blue, 2); end
212
+ def neon_pink(s); paint(s, :neon_pink, 2); end
213
+ def neon_yellow(s); paint(s, :neon_yellow, 2); end
214
+ def neon_orange(s); paint(s, :neon_orange, 2); end
215
+ def neon_purple(s); paint(s, :neon_purple, 2); end
216
+ def neon_magenta(s); paint(s, :neon_magenta, 2); end
217
+ def neon_aqua(s); paint(s, :neon_aqua, 2); end
218
+ def neon_lime(s); paint(s, :neon_lime, 2); end
219
+ def neon_white(s); paint(s, :neon_white, 2); end
220
+
177
221
  def r(s); bright_red(s); end
178
222
  def dr(s); dark_red(s); end
179
223
  def g(s); bright_green(s); end
@@ -183,6 +227,26 @@ class GRmenu
183
227
  def cy(s); bright_cyan(s); end
184
228
  def mg(s); bright_magenta(s); end
185
229
  def bl(s); bright_blue(s); end
230
+
231
+ def hex(code, text)
232
+ c = GRmenu.ansi_color(code.to_s)
233
+ "#{c}#{text}#{RESET}"
234
+ end
235
+
236
+ def respond_to_missing?(method_name, include_private = false)
237
+ GRmenu::COLORS.key?(method_name.to_s) || super
238
+ end
239
+
240
+ def method_missing(method_name, *args, &block)
241
+ m_str = method_name.to_s
242
+ if GRmenu::COLORS.key?(m_str)
243
+ text = args[0].to_s
244
+ lvl = args[1] || 2
245
+ paint(text, m_str, lvl)
246
+ else
247
+ super
248
+ end
249
+ end
186
250
  end
187
251
  C = Color
188
252
 
@@ -596,6 +660,9 @@ class GRmenu
596
660
  lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", tick)
597
661
  if @title && !@title.empty?
598
662
  t_str = @title.to_s
663
+ if GRmenu.display_width(t_str) > inner_w
664
+ t_str = t_str[0...[inner_w - 3, 1].max] + "..."
665
+ end
599
666
  pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
600
667
  l_p = " " * (pad_t / 2)
601
668
  r_p = " " * (pad_t - (pad_t / 2))
@@ -612,6 +679,9 @@ class GRmenu
612
679
  lines << "#{Color.rgb(v_l, tick)} #{bar_line} #{Color.rgb(v_r, tick)}"
613
680
  if @status && !@status.empty?
614
681
  st_str = @status.to_s
682
+ if GRmenu.display_width(st_str) > inner_w
683
+ st_str = st_str[0...[inner_w - 3, 1].max] + "..."
684
+ end
615
685
  pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
616
686
  st_line = st_str + (" " * pad_st)
617
687
  lines << "#{Color.rgb(v_l, tick)} #{Color.gray(st_line)} #{Color.rgb(v_r, tick)}"
@@ -628,6 +698,9 @@ class GRmenu
628
698
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
629
699
  if @title && !@title.empty?
630
700
  t_str = @title.to_s
701
+ if GRmenu.display_width(t_str) > inner_w
702
+ t_str = t_str[0...[inner_w - 3, 1].max] + "..."
703
+ end
631
704
  pad_t = [inner_w - GRmenu.display_width(t_str), 0].max
632
705
  l_p = " " * (pad_t / 2)
633
706
  r_p = " " * (pad_t - (pad_t / 2))
@@ -637,6 +710,9 @@ class GRmenu
637
710
  lines << "#{color_code}#{v_l}#{reset_code} #{color_code}#{bar_line}#{reset_code} #{color_code}#{v_r}#{reset_code}"
638
711
  if @status && !@status.empty?
639
712
  st_str = @status.to_s
713
+ if GRmenu.display_width(st_str) > inner_w
714
+ st_str = st_str[0...[inner_w - 3, 1].max] + "..."
715
+ end
640
716
  pad_st = [inner_w - GRmenu.display_width(st_str), 0].max
641
717
  st_line = st_str + (" " * pad_st)
642
718
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(st_line)} #{color_code}#{v_r}#{reset_code}"
@@ -662,7 +738,8 @@ class GRmenu
662
738
  end
663
739
  end
664
740
 
665
- def self.spinner(message = "Cargando...", color: "cyan", level: 2, delay: 0.08, &block)
741
+ def self.spinner(message_arg = nil, message: nil, color: "cyan", level: 2, delay: 0.08, &block)
742
+ actual_message = message || message_arg || "Cargando..."
666
743
  frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
667
744
  is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
668
745
  color_code = is_rgb ? "" : ansi_color(color, level)
@@ -674,7 +751,7 @@ class GRmenu
674
751
  while !stop_spinner
675
752
  f = frames[frame_idx % frames.length]
676
753
  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
754
+ msg_out = is_rgb ? Color.rgb(actual_message, frame_idx * 0.1) : actual_message
678
755
  Kernel.print("\r\e[K#{f_color}#{f}#{reset_code} #{msg_out}")
679
756
  $stdout.flush
680
757
  frame_idx += 1
@@ -688,13 +765,13 @@ class GRmenu
688
765
  stop_spinner = true
689
766
  spinner_thread.join
690
767
  success_color = ansi_color("green", 2)
691
- Kernel.print("\r\e[K#{success_color}[OK]#{reset_code} #{message} #{Color.gray("Listo!")}\r\n")
768
+ Kernel.print("\r\e[K#{success_color}[OK]#{reset_code} #{actual_message} #{Color.gray("Listo!")}\r\n")
692
769
  result
693
770
  rescue Exception => e
694
771
  stop_spinner = true
695
772
  spinner_thread.join rescue nil
696
773
  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")
774
+ Kernel.print("\r\e[K#{error_color}[ERROR]#{reset_code} #{actual_message} #{Color.bright_red("(Error: #{e.message})")}\r\n")
698
775
  raise e
699
776
  ensure
700
777
  stop_spinner = true
@@ -702,8 +779,9 @@ class GRmenu
702
779
  end
703
780
  end
704
781
 
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)
782
+ def self.progress(total_arg = nil, total: nil, title: nil, color: "cyan", level: 2, style: 3, width: nil, &block)
783
+ actual_total = total || total_arg || 100
784
+ bar = ProgressBar.new(actual_total, title: title, color: color, level: level, style: style, width: width)
707
785
  Kernel.print(HIDE_CURSOR)
708
786
  bar.render
709
787
  begin
@@ -715,10 +793,11 @@ class GRmenu
715
793
  end
716
794
  end
717
795
 
718
- def self.confirm(question = "¿Confirmar acción?", default: true, color: "cyan", style: 3)
796
+ def self.confirm(question_arg = nil, question: nil, default: true, color: "cyan", style: 3)
797
+ actual_question = question || question_arg || "¿Confirmar acción?"
719
798
  choice = default ? 0 : 1
720
799
  term_w = terminal_width
721
- q_w = display_width(question)
800
+ q_w = display_width(actual_question)
722
801
  box_w = [q_w + 8, term_w - 4, 38].max
723
802
  box_w = [box_w, 64].min
724
803
  inner_w = box_w - 4
@@ -748,20 +827,24 @@ class GRmenu
748
827
  right_p = " " * (pad_total - (pad_total / 2))
749
828
  btn_formatted_line = "#{left_p}#{btn_yes} #{btn_no}#{right_p}"
750
829
 
751
- pad_q = [inner_w - display_width(question), 0].max
830
+ q_clean = actual_question.to_s
831
+ if display_width(q_clean) > inner_w
832
+ q_clean = q_clean[0...[inner_w - 3, 1].max] + "..."
833
+ end
834
+ pad_q = [inner_w - display_width(q_clean), 0].max
752
835
  q_left = " " * (pad_q / 2)
753
836
  q_right = " " * (pad_q - (pad_q / 2))
754
837
 
755
838
  lines = []
756
839
  if is_rgb
757
840
  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)}"
841
+ lines << "#{Color.rgb(v_l)} #{q_left}#{q_clean}#{q_right} #{Color.rgb(v_r)}"
759
842
  lines << "#{Color.rgb(v_l)} #{' ' * inner_w} #{Color.rgb(v_r)}"
760
843
  lines << "#{Color.rgb(v_l)} #{btn_formatted_line} #{Color.rgb(v_r)}"
761
844
  lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
762
845
  else
763
846
  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}"
847
+ lines << "#{color_code}#{v_l}#{reset_code} #{q_left}#{q_clean}#{q_right} #{color_code}#{v_r}#{reset_code}"
765
848
  lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
766
849
  lines << "#{color_code}#{v_l}#{reset_code} #{btn_formatted_line} #{color_code}#{v_r}#{reset_code}"
767
850
  lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
@@ -812,54 +895,98 @@ class GRmenu
812
895
  result
813
896
  end
814
897
 
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
898
+ 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)
899
+ c_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "input")) || {}
900
+ style_num = (style || c_sec["style"] || 3).to_i
901
+ border_cfg = BORDERS[style_num] || BORDERS[3]
822
902
 
823
- border_cfg = BORDERS[style] || BORDERS[3]
824
903
  h_top = border_cfg[:ht] || border_cfg[:h]
825
904
  h_bot = border_cfg[:hb] || border_cfg[:h]
826
905
  v_l = border_cfg[:vl] || border_cfg[:v]
827
906
  v_r = border_cfg[:vr] || border_cfg[:v]
828
907
 
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
908
+ box_title = if title && !title.to_s.empty?
909
+ title.to_s
910
+ elsif prompt_or_title && !label
911
+ prompt_or_title.to_s
912
+ else
913
+ c_sec["title"] || "Entrada de Datos"
914
+ end
915
+
916
+ input_label = if label && !label.to_s.empty?
917
+ label.to_s
918
+ elsif prompt_or_title && title
919
+ prompt_or_title.to_s
920
+ elsif c_sec["label"]
921
+ c_sec["label"].to_s
922
+ else
923
+ "Valor:"
924
+ end
925
+
926
+ brd_col_name = (border_color || color || c_sec["border_color"] || c_sec["color"] || "cyan").to_s
927
+ tit_col_name = (title_color || c_sec["title_color"] || "yellow").to_s
928
+ lbl_col_name = (label_color || c_sec["label_color"] || "white").to_s
929
+
930
+ is_rgb = (brd_col_name.downcase == "rgb" || brd_col_name.downcase == "rainbow" || brd_col_name.downcase == "chroma")
832
931
 
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)]
932
+ text = String.new(default.to_s)
933
+ term_w = terminal_width
934
+ p_len = display_width(box_title) + 6
935
+ c_len = display_width(input_label) + display_width(text) + 12
936
+ box_w = width ? width.to_i : [p_len, c_len, 48].max
937
+ box_w = [box_w, term_w - 4].min
938
+ inner_w = [box_w - 2, 20].max
835
939
 
836
940
  drawn_lines = 0
837
941
 
838
942
  render_input = lambda do
839
943
  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)
944
+ avail_inp_w = [inner_w - display_width(input_label) - 4, 4].max
945
+ if display_width(display_str) > avail_inp_w
946
+ display_str = "..." + display_str[-[avail_inp_w - 3, 1].max..-1]
947
+ end
843
948
 
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))
949
+ brd_code = is_rgb ? "" : ansi_color(brd_col_name, 1)
950
+ tit_code = ansi_color(tit_col_name, 2)
951
+ lbl_code = ansi_color(lbl_col_name, 2)
952
+ rst = ansi_reset
847
953
 
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
954
+ t_clean = " #{box_title} "
955
+ t_w = display_width(t_clean)
956
+ l_pad = [(inner_w - t_w) / 2, 0].max
957
+ r_pad = [inner_w - t_w - l_pad, 0].max
958
+
959
+ top_fill_l = (h_top * l_pad)[0...l_pad]
960
+ top_fill_r = (h_top * r_pad)[0...r_pad]
961
+ bot_fill = (h_bot * inner_w)[0...inner_w]
862
962
 
963
+ top_line = if is_rgb
964
+ Color.rgb("#{border_cfg[:tl]}#{top_fill_l}") + tit_code + t_clean + Color.rgb("#{top_fill_r}#{border_cfg[:tr]}")
965
+ else
966
+ "#{brd_code}#{border_cfg[:tl]}#{top_fill_l}#{rst}#{tit_code}#{t_clean}#{rst}#{brd_code}#{top_fill_r}#{border_cfg[:tr]}#{rst}"
967
+ end
968
+
969
+ empty_line = if is_rgb
970
+ Color.rgb("#{v_l}#{' ' * inner_w}#{v_r}")
971
+ else
972
+ "#{brd_code}#{v_l}#{rst}#{' ' * inner_w}#{brd_code}#{v_r}#{rst}"
973
+ end
974
+
975
+ raw_content = " #{input_label} #{display_str}█"
976
+ content_pad = [inner_w - display_width(raw_content), 0].max
977
+ content_line = if is_rgb
978
+ "#{Color.rgb(v_l)} #{lbl_code}#{input_label}#{rst} #{Color.bright_white(display_str)}█#{' ' * content_pad}#{Color.rgb(v_r)}"
979
+ else
980
+ "#{brd_code}#{v_l}#{rst} #{lbl_code}#{input_label}#{rst} #{Color.bright_white(display_str)}█#{' ' * content_pad}#{brd_code}#{v_r}#{rst}"
981
+ end
982
+
983
+ bot_line = if is_rgb
984
+ Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")
985
+ else
986
+ "#{brd_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{rst}"
987
+ end
988
+
989
+ lines = [top_line, empty_line, content_line, empty_line, bot_line]
863
990
  frame = lines.join("\r\n") + "\r\n"
864
991
  Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
865
992
  Kernel.print(frame)
@@ -885,7 +1012,7 @@ class GRmenu
885
1012
  text.clear
886
1013
  render_input.call
887
1014
  elsif key =~ /^[[:print:]]$/
888
- text << key if text.length < (inner_w - 4)
1015
+ text << key if display_width(text) < (inner_w - display_width(input_label) - 6)
889
1016
  render_input.call
890
1017
  end
891
1018
  end
@@ -903,9 +1030,13 @@ class GRmenu
903
1030
  text
904
1031
  end
905
1032
 
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)
1033
+ 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: [])
1034
+ cb_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "checkbox")) || {}
1035
+ actual_items = items || items_arg || []
1036
+ item_list = actual_items.is_a?(Array) ? actual_items : Array(actual_items)
908
1037
  return [] if item_list.empty?
1038
+ chk_mark = cb_sec["checked_mark"] || "[X]"
1039
+ unchk_mark = cb_sec["unchecked_mark"] || "[ ]"
909
1040
 
910
1041
  parsed_items = item_list.map do |it|
911
1042
  case it
@@ -936,8 +1067,10 @@ class GRmenu
936
1067
  index = 0
937
1068
  rgb_tick = 0.0
938
1069
  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]
1070
+ cb_color = (color || cb_sec["color"] || "cyan").to_s
1071
+ style_num = (style || cb_sec["style"] || 3).to_i
1072
+ is_rgb = (cb_color.downcase == "rgb" || cb_color.downcase == "rainbow" || cb_color.downcase == "chroma")
1073
+ border_cfg = BORDERS[style_num] || BORDERS[3]
941
1074
  h_top = border_cfg[:ht] || border_cfg[:h]
942
1075
  h_bot = border_cfg[:hb] || border_cfg[:h]
943
1076
  v_l = border_cfg[:vl] || border_cfg[:v]
@@ -972,8 +1105,10 @@ class GRmenu
972
1105
  if is_rgb
973
1106
  lines << Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}", rgb_tick)
974
1107
  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)))
1108
+ t_clean = title.to_s
1109
+ t_clean = t_clean[0...[inner_w - 3, 1].max] + "..." if display_width(t_clean) > inner_w
1110
+ pad_t = [inner_w - display_width(t_clean), 0].max
1111
+ t_line = (" " * (pad_t / 2)) + t_clean + (" " * (pad_t - (pad_t / 2)))
977
1112
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(t_line, rgb_tick + 0.2)} #{Color.rgb(v_r, rgb_tick)}"
978
1113
  lines << Color.rgb("#{v_l}#{mid_fill}#{v_r}", rgb_tick)
979
1114
  end
@@ -984,11 +1119,13 @@ class GRmenu
984
1119
  end
985
1120
  (start_idx..end_idx).each do |i|
986
1121
  it = parsed_items[i]
987
- mark = it[:checked] ? "[X]" : "[ ]"
1122
+ mark = it[:checked] ? chk_mark : unchk_mark
988
1123
  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)
1124
+ max_name_w = [inner_w - display_width(mark) - 4, 4].max
1125
+ name_str = it[:name].to_s
1126
+ name_str = name_str[0...[max_name_w - 3, 1].max] + "..." if display_width(name_str) > max_name_w
1127
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{name_str}"
1128
+ line_padded = pad_to_width(raw_line, inner_w)
992
1129
  if is_active
993
1130
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.rgb(line_padded, rgb_tick + 0.4)} #{Color.rgb(v_r, rgb_tick)}"
994
1131
  elsif it[:checked]
@@ -1005,18 +1142,22 @@ class GRmenu
1005
1142
  end
1006
1143
  unless subtitle.to_s.empty?
1007
1144
  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)))
1145
+ s_clean = subtitle.to_s
1146
+ s_clean = s_clean[0...[inner_w - 3, 1].max] + "..." if display_width(s_clean) > inner_w
1147
+ pad_sub = [inner_w - display_width(s_clean), 0].max
1148
+ sub_padded = (" " * (pad_sub / 2)) + s_clean + (" " * (pad_sub - (pad_sub / 2)))
1010
1149
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(sub_padded)} #{Color.rgb(v_r, rgb_tick)}"
1011
1150
  end
1012
1151
  lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", rgb_tick)
1013
1152
  else
1014
- color_code = ansi_color(color, 2)
1153
+ color_code = ansi_color(cb_color, 2)
1015
1154
  reset_code = ansi_reset
1016
1155
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
1017
1156
  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)))
1157
+ t_clean = title.to_s
1158
+ t_clean = t_clean[0...[inner_w - 3, 1].max] + "..." if display_width(t_clean) > inner_w
1159
+ pad_t = [inner_w - display_width(t_clean), 0].max
1160
+ t_line = (" " * (pad_t / 2)) + t_clean + (" " * (pad_t - (pad_t / 2)))
1020
1161
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(t_line)} #{color_code}#{v_r}#{reset_code}"
1021
1162
  lines << "#{color_code}#{v_l}#{top_fill}#{v_r}#{reset_code}"
1022
1163
  end
@@ -1027,15 +1168,17 @@ class GRmenu
1027
1168
  end
1028
1169
  (start_idx..end_idx).each do |i|
1029
1170
  it = parsed_items[i]
1030
- mark = it[:checked] ? "[X]" : "[ ]"
1171
+ mark = it[:checked] ? chk_mark : unchk_mark
1031
1172
  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)
1173
+ max_name_w = [inner_w - display_width(mark) - 4, 4].max
1174
+ name_str = it[:name].to_s
1175
+ name_str = name_str[0...[max_name_w - 3, 1].max] + "..." if display_width(name_str) > max_name_w
1176
+ raw_line = "#{is_active ? '> ' : ' '}#{mark} #{name_str}"
1177
+ line_padded = pad_to_width(raw_line, inner_w)
1035
1178
  if is_active
1036
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1179
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(line_padded)} #{color_code}#{v_r}#{reset_code}"
1037
1180
  elsif it[:checked]
1038
- lines << "#{color_code}#{v_l}#{reset_code} #{Color.green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1181
+ lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_green(line_padded)} #{color_code}#{v_r}#{reset_code}"
1039
1182
  else
1040
1183
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.white(line_padded)} #{color_code}#{v_r}#{reset_code}"
1041
1184
  end
@@ -1048,8 +1191,10 @@ class GRmenu
1048
1191
  end
1049
1192
  unless subtitle.to_s.empty?
1050
1193
  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)))
1194
+ s_clean = subtitle.to_s
1195
+ s_clean = s_clean[0...[inner_w - 3, 1].max] + "..." if display_width(s_clean) > inner_w
1196
+ pad_sub = [inner_w - display_width(s_clean), 0].max
1197
+ sub_padded = (" " * (pad_sub / 2)) + s_clean + (" " * (pad_sub - (pad_sub / 2)))
1053
1198
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(sub_padded)} #{color_code}#{v_r}#{reset_code}"
1054
1199
  end
1055
1200
  lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
@@ -1139,14 +1284,18 @@ class GRmenu
1139
1284
  alias_method :multiselect, :checkbox
1140
1285
  end
1141
1286
 
1142
- def self.slider(prompt = "Selecciona un valor:", min: 0, max: 100, step: 1, default: nil, unit: "", color: "cyan", style: 3, width: 46)
1287
+ def self.slider(prompt_arg = nil, prompt: nil, min: 0, max: 100, step: 1, default: nil, unit: "", color: nil, style: nil, width: 46)
1288
+ actual_prompt = prompt || prompt_arg || "Selecciona un valor:"
1289
+ sl_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "slider")) || {}
1143
1290
  val = (default || min).to_f.clamp(min.to_f, max.to_f)
1144
1291
  step_val = [step.to_f, 0.001].max
1145
1292
  drawn_lines = 0
1146
1293
  rgb_tick = 0.0
1147
- is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
1294
+ sl_color = (color || sl_sec["color"] || "cyan").to_s
1295
+ style_num = (style || sl_sec["style"] || 3).to_i
1296
+ is_rgb = (sl_color.downcase == "rgb" || sl_color.downcase == "rainbow" || sl_color.downcase == "chroma")
1148
1297
 
1149
- border_cfg = BORDERS[style] || BORDERS[3]
1298
+ border_cfg = BORDERS[style_num] || BORDERS[3]
1150
1299
  h_top = border_cfg[:ht] || border_cfg[:h]
1151
1300
  h_bot = border_cfg[:hb] || border_cfg[:h]
1152
1301
  v_l = border_cfg[:vl] || border_cfg[:v]
@@ -1154,7 +1303,7 @@ class GRmenu
1154
1303
 
1155
1304
  render_slider = lambda do
1156
1305
  term_w = terminal_width
1157
- box_w = [width, term_w - 4, display_width(prompt) + 8, 38].max
1306
+ box_w = [width, term_w - 4, display_width(actual_prompt) + 8, 38].max
1158
1307
  box_w = [box_w, term_w - 2].min
1159
1308
  inner_w = box_w - 4
1160
1309
 
@@ -1168,14 +1317,21 @@ class GRmenu
1168
1317
  range_span = 1.0 if range_span <= 0
1169
1318
  fraction = ((val - min).to_f / range_span).clamp(0.0, 1.0)
1170
1319
 
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
1320
+ avail_bar_w = [inner_w - display_width(val_str) - 4, 6].max
1321
+ filled_len = (fraction * avail_bar_w).round
1322
+ empty_len = [avail_bar_w - filled_len, 0].max
1174
1323
 
1175
- pad_p = [inner_w - display_width(prompt), 0].max
1176
- p_line = (" " * (pad_p / 2)) + prompt + (" " * (pad_p - (pad_p / 2)))
1324
+ p_clean = actual_prompt.to_s
1325
+ if display_width(p_clean) > inner_w
1326
+ p_clean = p_clean[0...[inner_w - 3, 1].max] + "..."
1327
+ end
1328
+ pad_p = [inner_w - display_width(p_clean), 0].max
1329
+ p_line = (" " * (pad_p / 2)) + p_clean + (" " * (pad_p - (pad_p / 2)))
1177
1330
 
1178
- instr = "← / → Ajustar | Enter Guardar"
1331
+ instr = (inner_w >= 30) ? "← / → Ajustar | Enter Guardar" : "←/→: Ajustar | Enter: Ok"
1332
+ if display_width(instr) > inner_w
1333
+ instr = instr[0...[inner_w - 3, 1].max] + "..."
1334
+ end
1179
1335
  pad_i = [inner_w - display_width(instr), 0].max
1180
1336
  i_line = (" " * (pad_i / 2)) + instr + (" " * (pad_i - (pad_i / 2)))
1181
1337
 
@@ -1188,22 +1344,24 @@ class GRmenu
1188
1344
  filled_part = Color.rgb("█" * filled_len, rgb_tick + 0.5)
1189
1345
  empty_part = Color.gray("░" * empty_len)
1190
1346
  bar_raw = "[#{filled_part}#{empty_part}] #{Color.bright_white(val_str)}"
1191
- pad_b = [inner_w - (bar_w + 3 + val_str.length), 0].max
1347
+ bar_vis_w = 2 + filled_len + empty_len + 1 + display_width(val_str)
1348
+ pad_b = [inner_w - bar_vis_w, 0].max
1192
1349
  lines << "#{Color.rgb(v_l, rgb_tick)} #{bar_raw}#{' ' * pad_b} #{Color.rgb(v_r, rgb_tick)}"
1193
1350
  lines << "#{Color.rgb(v_l, rgb_tick)} #{' ' * inner_w} #{Color.rgb(v_r, rgb_tick)}"
1194
1351
  lines << "#{Color.rgb(v_l, rgb_tick)} #{Color.gray(i_line)} #{Color.rgb(v_r, rgb_tick)}"
1195
1352
  lines << Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}", rgb_tick)
1196
1353
  else
1197
- color_code = ansi_color(color, 2)
1354
+ color_code = ansi_color(sl_color, 2)
1198
1355
  reset_code = ansi_reset
1199
1356
 
1200
1357
  bar_raw = "[#{"█" * filled_len}#{"░" * empty_len}] #{val_str}"
1201
1358
  pad_b = [inner_w - display_width(bar_raw), 0].max
1359
+ bar_line = bar_raw + (" " * pad_b)
1202
1360
 
1203
1361
  lines << "#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}"
1204
1362
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.bright_yellow(p_line)} #{color_code}#{v_r}#{reset_code}"
1205
1363
  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}"
1364
+ lines << "#{color_code}#{v_l}#{reset_code} #{bar_line} #{color_code}#{v_r}#{reset_code}"
1207
1365
  lines << "#{color_code}#{v_l}#{reset_code} #{' ' * inner_w} #{color_code}#{v_r}#{reset_code}"
1208
1366
  lines << "#{color_code}#{v_l}#{reset_code} #{Color.gray(i_line)} #{color_code}#{v_r}#{reset_code}"
1209
1367
  lines << "#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}"
@@ -1280,31 +1438,36 @@ class GRmenu
1280
1438
  end
1281
1439
 
1282
1440
  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
1441
+ is_tty = input_stream.respond_to?(:tty?) && input_stream.tty?
1442
+ first_char = nil
1443
+ begin
1444
+ first_char = is_tty ? input_stream.getch : input_stream.read(1)
1445
+ rescue EOFError, Errno::EPIPE
1446
+ return nil
1290
1447
  end
1291
-
1292
- first_char = input_stream.getch
1293
1448
  return nil if first_char.nil?
1294
1449
 
1295
1450
  if first_char == "\e"
1296
1451
  begin
1297
- extra_chars = input_stream.read_nonblock(2)
1298
- first_char << extra_chars
1452
+ second = is_tty ? input_stream.read_nonblock(1) : input_stream.read(1)
1453
+ if second
1454
+ first_char << second
1455
+ if second == "[" || second == "O"
1456
+ while true
1457
+ ch = is_tty ? input_stream.read_nonblock(1) : input_stream.read(1)
1458
+ break if ch.nil?
1459
+ first_char << ch
1460
+ break if ch =~ /[a-zA-Z~]/
1461
+ end
1462
+ end
1463
+ end
1299
1464
  rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1300
1465
  end
1301
1466
  elsif first_char == "\x00" || first_char == "\xe0"
1302
1467
  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
1468
+ second_char = is_tty ? input_stream.read_nonblock(1) : input_stream.read(1)
1307
1469
  first_char << second_char if second_char
1470
+ rescue IO::WaitReadable, IO::EAGAINWaitReadable, EOFError
1308
1471
  end
1309
1472
  end
1310
1473
 
@@ -1322,7 +1485,8 @@ class GRmenu
1322
1485
  banner: { color: "magenta", level: 2 },
1323
1486
  subtitle: { color: "cyan", level: 2 },
1324
1487
  divider: { color: "blue", level: 1 },
1325
- font: 1
1488
+ font: 1,
1489
+ desc_prefix: "[i]"
1326
1490
  )
1327
1491
  @border = border.dup
1328
1492
  @options = options.dup
@@ -1332,11 +1496,22 @@ class GRmenu
1332
1496
  @subtitle = subtitle.dup
1333
1497
  @divider = divider.dup
1334
1498
  @font = font.to_i
1499
+ @desc_prefix = desc_prefix.to_s
1335
1500
  end
1336
1501
 
1502
+ def desc_prefix(prefix_str = nil)
1503
+ return @desc_prefix if prefix_str.nil?
1504
+ @desc_prefix = prefix_str.to_s
1505
+ self
1506
+ end
1507
+ alias_method :description_prefix, :desc_prefix
1508
+ alias_method :desc_prefix=, :desc_prefix
1509
+ alias_method :description_prefix=, :desc_prefix
1510
+
1337
1511
  def border(color_name = nil, brightness_level = 1)
1338
1512
  return @border if color_name.nil?
1339
1513
  @border = parse_color(color_name, brightness_level)
1514
+ self
1340
1515
  end
1341
1516
  alias_method :Border, :border
1342
1517
  alias_method :set_border, :border
@@ -1345,6 +1520,7 @@ class GRmenu
1345
1520
  def options(color_name = nil, brightness_level = 1)
1346
1521
  return @options if color_name.nil?
1347
1522
  @options = parse_color(color_name, brightness_level)
1523
+ self
1348
1524
  end
1349
1525
  alias_method :Options, :options
1350
1526
  alias_method :set_options, :options
@@ -1353,6 +1529,7 @@ class GRmenu
1353
1529
  def focus(color_name = nil, brightness_level = 2)
1354
1530
  return @focus if color_name.nil?
1355
1531
  @focus = parse_color(color_name, brightness_level)
1532
+ self
1356
1533
  end
1357
1534
  alias_method :Focus, :focus
1358
1535
  alias_method :set_focus, :focus
@@ -1361,6 +1538,7 @@ class GRmenu
1361
1538
  def title(color_name = nil, brightness_level = 2)
1362
1539
  return @title if color_name.nil?
1363
1540
  @title = parse_color(color_name, brightness_level)
1541
+ self
1364
1542
  end
1365
1543
  alias_method :Title, :title
1366
1544
  alias_method :set_title, :title
@@ -1369,6 +1547,7 @@ class GRmenu
1369
1547
  def banner(color_name = nil, brightness_level = 2)
1370
1548
  return @banner if color_name.nil?
1371
1549
  @banner = parse_color(color_name, brightness_level)
1550
+ self
1372
1551
  end
1373
1552
  alias_method :Banner, :banner
1374
1553
  alias_method :set_banner, :banner
@@ -1377,6 +1556,7 @@ class GRmenu
1377
1556
  def subtitle(color_name = nil, brightness_level = 2)
1378
1557
  return @subtitle if color_name.nil?
1379
1558
  @subtitle = parse_color(color_name, brightness_level)
1559
+ self
1380
1560
  end
1381
1561
  alias_method :Subtitle, :subtitle
1382
1562
  alias_method :set_subtitle, :subtitle
@@ -1385,6 +1565,7 @@ class GRmenu
1385
1565
  def divider(color_name = nil, brightness_level = 1)
1386
1566
  return @divider if color_name.nil?
1387
1567
  @divider = parse_color(color_name, brightness_level)
1568
+ self
1388
1569
  end
1389
1570
  alias_method :Divider, :divider
1390
1571
  alias_method :set_divider, :divider
@@ -1393,6 +1574,7 @@ class GRmenu
1393
1574
  def font(font_id = nil)
1394
1575
  return @font if font_id.nil?
1395
1576
  @font = font_id.to_i
1577
+ self
1396
1578
  end
1397
1579
  alias_method :Font, :font
1398
1580
  alias_method :set_font, :font
@@ -1473,6 +1655,15 @@ class GRmenu
1473
1655
  alias_method :Font, :font
1474
1656
  alias_method :set_font, :font
1475
1657
  alias_method :font=, :font
1658
+
1659
+ def desc_prefix(prefix_str = nil)
1660
+ @default_desc_prefix ||= "[i]"
1661
+ return @default_desc_prefix if prefix_str.nil?
1662
+ @default_desc_prefix = prefix_str.to_s
1663
+ end
1664
+ alias_method :description_prefix, :desc_prefix
1665
+ alias_method :desc_prefix=, :desc_prefix
1666
+ alias_method :description_prefix=, :desc_prefix
1476
1667
  end
1477
1668
  end
1478
1669
 
@@ -1524,6 +1715,713 @@ class GRmenu
1524
1715
  alias_method :clr, :clear_screen
1525
1716
  end
1526
1717
 
1718
+ @@global_theme = {}
1719
+
1720
+ def self.current_theme
1721
+ @@global_theme
1722
+ end
1723
+
1724
+ def self.parse_config_text(text)
1725
+ data = { global: {}, sections: {} }
1726
+ current_sec = nil
1727
+ current_sec_data = {}
1728
+
1729
+ text.to_s.each_line do |line|
1730
+ line = line.strip
1731
+ next if line.empty? || line.start_with?("#")
1732
+ next if line.start_with?("GRmenu::config")
1733
+
1734
+ if line.start_with?("<<")
1735
+ sec_name = line[2..-1].strip.downcase
1736
+ current_sec = sec_name
1737
+ current_sec_data = {}
1738
+ elsif line == ">>"
1739
+ if current_sec
1740
+ data[:sections][current_sec] = current_sec_data
1741
+ current_sec = nil
1742
+ end
1743
+ elsif line.include?("::")
1744
+ key, _, val = line.partition("::")
1745
+ key = key.strip.sub(/^@/, '').downcase
1746
+ val = val.strip.sub(/^["']/, '').sub(/["']$/, '')
1747
+ if current_sec
1748
+ current_sec_data[key] = val
1749
+ else
1750
+ data[:global][key] = val
1751
+ end
1752
+ end
1753
+ end
1754
+ data
1755
+ end
1756
+
1757
+ def self.find_theme_file(path_or_name)
1758
+ name = path_or_name.to_s
1759
+ candidates = [
1760
+ name,
1761
+ "#{name}.gr",
1762
+ find_data_file("themes/#{name}.gr"),
1763
+ find_data_file("themes/#{name}"),
1764
+ File.expand_path("data/themes/#{name}.gr", __dir__),
1765
+ File.expand_path("data/themes/#{name}", __dir__),
1766
+ File.expand_path("../data/themes/#{name}.gr", __dir__),
1767
+ File.expand_path("../data/themes/#{name}", __dir__)
1768
+ ].compact
1769
+ candidates.find { |p| File.exist?(p) }
1770
+ end
1771
+
1772
+ def self.import_config(path_or_name)
1773
+ path = find_theme_file(path_or_name)
1774
+ raise "No se encontro el tema: #{path_or_name}" unless path && File.exist?(path)
1775
+ text = File.read(path)
1776
+ parsed = parse_config_text(text)
1777
+ apply_parsed_theme(parsed)
1778
+ @@global_theme = parsed
1779
+ path
1780
+ end
1781
+
1782
+ def self.theme(name)
1783
+ import_config(name)
1784
+ end
1785
+
1786
+ def self.extract_color_and_level(val, default_level = 1)
1787
+ return ["white", default_level] if val.nil?
1788
+ parts = val.to_s.split(":")
1789
+ c_name = parts[0].to_s.strip
1790
+ lvl = parts[1] ? parts[1].to_i : default_level
1791
+ [c_name, lvl]
1792
+ end
1793
+
1794
+ def self.apply_parsed_theme(parsed)
1795
+ sec = parsed[:sections] || {}
1796
+ glob = parsed[:global] || {}
1797
+ m = (sec["menu"] || {}).merge(glob)
1798
+
1799
+ if m && !m.empty?
1800
+ if m["border"] || m["border_color"]
1801
+ c, l = extract_color_and_level(m["border"] || m["border_color"], 1)
1802
+ SetStyle.border(c, l)
1803
+ end
1804
+ if m["title"] || m["title_color"]
1805
+ c, l = extract_color_and_level(m["title"] || m["title_color"], 2)
1806
+ SetStyle.title(c, l)
1807
+ end
1808
+ if m["focus"] || m["focus_color"]
1809
+ c, l = extract_color_and_level(m["focus"] || m["focus_color"], 2)
1810
+ SetStyle.focus(c, l)
1811
+ end
1812
+ if m["options"] || m["options_color"]
1813
+ c, l = extract_color_and_level(m["options"] || m["options_color"], 1)
1814
+ SetStyle.options(c, l)
1815
+ end
1816
+ if m["banner"] || m["banner_color"]
1817
+ c, l = extract_color_and_level(m["banner"] || m["banner_color"], 2)
1818
+ SetStyle.banner(c, l)
1819
+ end
1820
+ if m["subtitle"] || m["subtitle_color"]
1821
+ c, l = extract_color_and_level(m["subtitle"] || m["subtitle_color"], 1)
1822
+ SetStyle.subtitle(c, l)
1823
+ end
1824
+ if m["divider"] || m["divider_color"]
1825
+ c, l = extract_color_and_level(m["divider"] || m["divider_color"], 1)
1826
+ SetStyle.divider(c, l)
1827
+ end
1828
+ if m["desc_prefix"] || m["description_prefix"] || m["prefix"]
1829
+ SetStyle.desc_prefix(m["desc_prefix"] || m["description_prefix"] || m["prefix"])
1830
+ end
1831
+ SetStyle.font(m["font"].to_i) if m["font"]
1832
+ end
1833
+
1834
+ sec.each do |k, v|
1835
+ next unless v.is_a?(Hash)
1836
+ c_val = v["color"] || v["border"] || v["options"] || v["focus"] || v["title"] || v["banner"] || v["subtitle"] || v["divider"]
1837
+ c, l = extract_color_and_level(c_val, (v["level"] || 1).to_i)
1838
+ case k
1839
+ when "border"
1840
+ SetStyle.border(c, l)
1841
+ when "options"
1842
+ SetStyle.options(c, l)
1843
+ when "focus"
1844
+ SetStyle.focus(c, l)
1845
+ when "title"
1846
+ SetStyle.title(c, l)
1847
+ when "banner"
1848
+ SetStyle.banner(c, l)
1849
+ when "subtitle"
1850
+ SetStyle.subtitle(c, l)
1851
+ when "divider"
1852
+ SetStyle.divider(c, l)
1853
+ end
1854
+ end
1855
+ SetStyle.font(glob["font"].to_i) if glob["font"]
1856
+ end
1857
+
1858
+ def self.style(css_content)
1859
+ parsed = parse_config_text(css_content)
1860
+ apply_parsed_theme(parsed)
1861
+ parsed
1862
+ end
1863
+
1864
+ def self.export_config(path = nil)
1865
+ if path.nil?
1866
+ caller_loc = caller_locations.find { |c| !c.path.include?(__FILE__) }
1867
+ base = caller_loc ? caller_loc.path.sub(/\.rb$/, '') : "theme"
1868
+ path = "#{base}.gr"
1869
+ end
1870
+ lines = ["GRmenu::config<-1->", ""]
1871
+ lines << "@theme:: \"#{File.basename(path, '.gr').capitalize}\""
1872
+ lines << "@author:: \"grcode\""
1873
+ lines << "@version:: \"1.0\""
1874
+ lines << ""
1875
+ lines << "<<menu"
1876
+ lines << " style:: 3"
1877
+ lines << " banner_style:: 3"
1878
+ lines << " font:: #{SetStyle.font}"
1879
+ lines << " animate:: rgb"
1880
+ lines << " center:: true"
1881
+ lines << " border:: #{SetStyle.border[:color]}:#{SetStyle.border[:level]}"
1882
+ lines << " title:: #{SetStyle.title[:color]}:#{SetStyle.title[:level]}"
1883
+ lines << " focus:: #{SetStyle.focus[:color]}:#{SetStyle.focus[:level]}"
1884
+ lines << " options:: #{SetStyle.options[:color]}:#{SetStyle.options[:level]}"
1885
+ lines << " banner:: #{SetStyle.banner[:color]}:#{SetStyle.banner[:level]}"
1886
+ lines << " subtitle:: #{SetStyle.subtitle[:color]}:#{SetStyle.subtitle[:level]}"
1887
+ lines << " divider:: #{SetStyle.divider[:color]}:#{SetStyle.divider[:level]}"
1888
+ lines << ">>"
1889
+ lines << ""
1890
+ lines << "<<table"
1891
+ lines << " style:: 3"
1892
+ lines << " header_color:: yellow:2"
1893
+ lines << " border_color:: rgb:2"
1894
+ lines << " selected_row:: green:2"
1895
+ lines << " row_color:: white:1"
1896
+ lines << " zebra_striping:: true"
1897
+ lines << ">>"
1898
+ lines << ""
1899
+ lines << "<<card"
1900
+ lines << " style:: 7"
1901
+ lines << " border_color:: cyan:2"
1902
+ lines << " title_color:: yellow:2"
1903
+ lines << " content_color:: white:1"
1904
+ lines << ">>"
1905
+ lines << ""
1906
+ lines << "<<slider"
1907
+ lines << " style:: 3"
1908
+ lines << " color:: rgb:2"
1909
+ lines << " fill_char:: █"
1910
+ lines << " empty_char:: ░"
1911
+ lines << ">>"
1912
+ lines << ""
1913
+ lines << "<<checkbox"
1914
+ lines << " style:: 3"
1915
+ lines << " color:: rgb:2"
1916
+ lines << " checked_mark:: [X]"
1917
+ lines << " unchecked_mark:: [ ]"
1918
+ lines << ">>"
1919
+ lines << ""
1920
+ File.write(path, lines.join("\n") + "\n")
1921
+ path
1922
+ end
1923
+
1924
+ def self.export_from_file(source_file, target_path = nil)
1925
+ raise "No existe #{source_file}" unless File.exist?(source_file)
1926
+ orig_draw = instance_method(:draw) rescue nil
1927
+ extracted = nil
1928
+ define_method(:draw) do |*|
1929
+ extracted = {
1930
+ style: @style,
1931
+ banner_style: @banner_style,
1932
+ font: @style_config&.font,
1933
+ animate: @animate,
1934
+ border: @style_config&.border,
1935
+ title: @style_config&.title,
1936
+ focus: @style_config&.focus,
1937
+ options: @style_config&.options,
1938
+ banner: @style_config&.banner,
1939
+ subtitle: @style_config&.subtitle,
1940
+ divider: @style_config&.divider
1941
+ }
1942
+ throw :grmenu_export_completed
1943
+ end
1944
+ begin
1945
+ catch(:grmenu_export_completed) do
1946
+ load(File.expand_path(source_file))
1947
+ end
1948
+ ensure
1949
+ define_method(:draw, orig_draw) if orig_draw
1950
+ end
1951
+ out = target_path || source_file.sub(/\.rb$/, '') + ".gr"
1952
+ if extracted && extracted[:border]
1953
+ lines = ["GRmenu::config<-1->", ""]
1954
+ lines << "@theme:: \"#{File.basename(out, '.gr').capitalize}\""
1955
+ lines << "@author:: \"grcode\""
1956
+ lines << "@version:: \"1.0\""
1957
+ lines << ""
1958
+ lines << "<<menu"
1959
+ lines << " style:: #{extracted[:style] || 3}"
1960
+ lines << " banner_style:: #{extracted[:banner_style] || 3}"
1961
+ lines << " font:: #{extracted[:font] || 1}"
1962
+ lines << " animate:: #{extracted[:animate] || 'rgb'}"
1963
+ lines << " center:: true"
1964
+ lines << " border:: #{extracted[:border][:color]}:#{extracted[:border][:level]}"
1965
+ lines << " title:: #{extracted[:title][:color]}:#{extracted[:title][:level]}"
1966
+ lines << " focus:: #{extracted[:focus][:color]}:#{extracted[:focus][:level]}"
1967
+ lines << " options:: #{extracted[:options][:color]}:#{extracted[:options][:level]}"
1968
+ lines << " banner:: #{extracted[:banner][:color]}:#{extracted[:banner][:level]}"
1969
+ lines << " subtitle:: #{extracted[:subtitle][:color]}:#{extracted[:subtitle][:level]}"
1970
+ lines << " divider:: #{extracted[:divider][:color]}:#{extracted[:divider][:level]}"
1971
+ lines << ">>"
1972
+ lines << ""
1973
+ lines << "<<table"
1974
+ lines << " style:: #{extracted[:style] || 3}"
1975
+ lines << " header_color:: yellow:2"
1976
+ lines << " border_color:: rgb:2"
1977
+ lines << " selected_row:: green:2"
1978
+ lines << " row_color:: white:1"
1979
+ lines << " zebra_striping:: true"
1980
+ lines << ">>"
1981
+ lines << ""
1982
+ lines << "<<card"
1983
+ lines << " style:: 7"
1984
+ lines << " border_color:: cyan:2"
1985
+ lines << " title_color:: yellow:2"
1986
+ lines << " content_color:: white:1"
1987
+ lines << ">>"
1988
+ lines << ""
1989
+ lines << "<<slider"
1990
+ lines << " style:: 3"
1991
+ lines << " color:: rgb:2"
1992
+ lines << " fill_char:: █"
1993
+ lines << " empty_char:: ░"
1994
+ lines << ">>"
1995
+ lines << ""
1996
+ lines << "<<checkbox"
1997
+ lines << " style:: 3"
1998
+ lines << " color:: rgb:2"
1999
+ lines << " checked_mark:: [X]"
2000
+ lines << " unchecked_mark:: [ ]"
2001
+ lines << ">>"
2002
+ lines << ""
2003
+ File.write(out, lines.join("\n") + "\n")
2004
+ out
2005
+ else
2006
+ export_config(out)
2007
+ end
2008
+ end
2009
+
2010
+ def self.split_ansi_chars(str)
2011
+ segments = []
2012
+ current_style = String.new("")
2013
+ in_escape = false
2014
+ escape_buf = String.new("")
2015
+
2016
+ str.to_s.each_char do |ch|
2017
+ if ch == "\e"
2018
+ in_escape = true
2019
+ escape_buf << ch
2020
+ next
2021
+ end
2022
+ if in_escape
2023
+ escape_buf << ch
2024
+ if ch =~ /[a-zA-Z]/
2025
+ in_escape = false
2026
+ current_style = escape_buf.dup
2027
+ escape_buf.clear
2028
+ end
2029
+ next
2030
+ end
2031
+ segments << { char: ch, style: current_style.dup }
2032
+ end
2033
+ segments
2034
+ end
2035
+
2036
+ def self.animate_render(lines, type = :diagonal, delay = 0.012)
2037
+ type_str = type.to_s.downcase
2038
+ return if lines.nil? || lines.empty?
2039
+ rst = ansi_reset
2040
+
2041
+ case type_str
2042
+ when "diagonal"
2043
+ parsed_rows = lines.map { |l| split_ansi_chars(l) }
2044
+ max_len = parsed_rows.map(&:length).max || 0
2045
+ total_steps = max_len + (parsed_rows.length * 2)
2046
+ step = 0
2047
+ while step <= total_steps
2048
+ buffer = String.new(CURSOR_HOME)
2049
+ parsed_rows.each_with_index do |row_segs, y|
2050
+ rendered_row = String.new("")
2051
+ row_segs.each_with_index do |seg, x|
2052
+ if (x + y * 2) <= step
2053
+ rendered_row << seg[:style] << seg[:char] << rst
2054
+ else
2055
+ rendered_row << " "
2056
+ end
2057
+ end
2058
+ buffer << rendered_row << CLEAR_TO_EOL << "\r\n"
2059
+ end
2060
+ buffer << CLEAR_TO_EOS
2061
+ Kernel.print(buffer)
2062
+ $stdout.flush
2063
+ sleep(delay)
2064
+ step += 4
2065
+ end
2066
+ when "linear"
2067
+ buffer = String.new(CURSOR_HOME)
2068
+ lines.each do |line|
2069
+ Kernel.print("#{line}#{CLEAR_TO_EOL}\r\n")
2070
+ $stdout.flush
2071
+ sleep(delay * 3)
2072
+ end
2073
+ when "fade"
2074
+ [1, 2].each do |lvl|
2075
+ buffer = String.new(CURSOR_HOME)
2076
+ lines.each do |line|
2077
+ clean = line.gsub(/\e\[[0-9;]*m/, '')
2078
+ buffer << ansi_color("white", lvl) << clean << rst << CLEAR_TO_EOL << "\r\n"
2079
+ end
2080
+ buffer << CLEAR_TO_EOS
2081
+ Kernel.print(buffer)
2082
+ $stdout.flush
2083
+ sleep(delay * 8)
2084
+ end
2085
+ end
2086
+ end
2087
+
2088
+ def self.alert(type, message, title: nil, style: 3, color: nil, border_color: nil, title_color: nil, pause: true)
2089
+ type_sym = type.to_sym rescue :info
2090
+ tag, def_col, def_title = case type_sym
2091
+ when :success, :ok
2092
+ ["[✔ EXITO]", "green", "Operacion Exitosa"]
2093
+ when :error, :fail, :danger
2094
+ ["[✖ ERROR]", "red", "Error en el Sistema"]
2095
+ when :warning, :warn
2096
+ ["[⚠ AVISO]", "yellow", "Advertencia"]
2097
+ else
2098
+ ["[ℹ INFO]", "cyan", "Informacion"]
2099
+ end
2100
+ card_col = border_color || color || def_col
2101
+ card_title = title || "#{tag} #{def_title}"
2102
+ card(title: card_title, content: message, style: style, color: card_col, title_color: title_color, pause: pause)
2103
+ end
2104
+
2105
+ 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)
2106
+ c_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "card")) || {}
2107
+ actual_title = title || (content_arg ? title_or_content : nil)
2108
+ actual_content = (content || (content_arg ? content_arg : title_or_content) || "").to_s
2109
+ style_num = (style || c_sec["style"] || 7).to_i
2110
+ border_cfg = BORDERS[style_num] || BORDERS[7]
2111
+ card_color = (border_color || color || c_sec["border_color"] || c_sec["color"] || "cyan").to_s
2112
+ actual_title_color = (title_color || c_sec["title_color"] || "yellow").to_s
2113
+ actual_content_color = (content_color || c_sec["content_color"] || "white").to_s
2114
+ is_rgb = card_color.downcase == "rgb" || card_color.downcase == "rainbow" || card_color.downcase == "chroma"
2115
+
2116
+ lines = actual_content.split("\n")
2117
+ content_max = lines.map { |l| display_width(l) }.max || 0
2118
+ box_w = width || [content_max + 6, actual_title ? display_width(actual_title) + 6 : 0, 46].max
2119
+ box_w = [box_w, terminal_width - 2].min
2120
+ inner_w = box_w - 2
2121
+
2122
+ wrapped_lines = []
2123
+ lines.each do |raw_l|
2124
+ if display_width(raw_l) <= (inner_w - 2)
2125
+ wrapped_lines << raw_l
2126
+ else
2127
+ cur = String.new("")
2128
+ raw_l.split(" ").each do |w|
2129
+ if cur.empty?
2130
+ cur << w
2131
+ elsif display_width("#{cur} #{w}") <= (inner_w - 2)
2132
+ cur << " " << w
2133
+ else
2134
+ wrapped_lines << cur
2135
+ cur = String.new(w)
2136
+ end
2137
+ end
2138
+ wrapped_lines << cur unless cur.empty?
2139
+ end
2140
+ end
2141
+
2142
+ tl = border_cfg[:tl] || "#"
2143
+ tr = border_cfg[:tr] || "#"
2144
+ bl = border_cfg[:bl] || "#"
2145
+ br = border_cfg[:br] || "#"
2146
+ h_char = border_cfg[:h] || "─"
2147
+ v_char = border_cfg[:v] || "│"
2148
+
2149
+ brd_col = is_rgb ? Color.rgb("").sub(/\e\[0m$/, '') : ansi_color(card_color, 1)
2150
+ rst = ansi_reset
2151
+
2152
+ top_str = if actual_title && !actual_title.empty?
2153
+ t_clean = " #{actual_title} "
2154
+ t_len = display_width(t_clean)
2155
+ if t_len > inner_w
2156
+ t_clean = " #{actual_title[0...[inner_w - 6, 1].max]}... "
2157
+ t_len = display_width(t_clean)
2158
+ end
2159
+ l_len = [(inner_w - t_len) / 2, 0].max
2160
+ r_len = [inner_w - t_len - l_len, 0].max
2161
+ h_char * l_len + ansi_color(actual_title_color, 2) + t_clean + brd_col + h_char * r_len
2162
+ else
2163
+ h_char * inner_w
2164
+ end
2165
+
2166
+ out = +""
2167
+ out << "#{brd_col}#{tl}#{top_str}#{tr}#{rst}\r\n"
2168
+ wrapped_lines.each do |line|
2169
+ pad_line = " " + line
2170
+ 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"
2171
+ end
2172
+ out << "#{brd_col}#{bl}#{h_char * inner_w}#{br}#{rst}\r\n"
2173
+
2174
+ Kernel.print(out)
2175
+ self.continue if pause
2176
+ end
2177
+
2178
+ 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)
2179
+ t_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "table")) || {}
2180
+ input_stream = $stdin
2181
+ output_stream = $stdout
2182
+ style_num = (style || t_sec["style"] || 3).to_i
2183
+ border_cfg = BORDERS[style_num] || BORDERS[3]
2184
+ tbl_color = (border_color || color || t_sec["border_color"] || t_sec["border"] || t_sec["color"] || "cyan").to_s
2185
+ header_color = header_color || t_sec["header_color"] || "yellow"
2186
+ focus_color = selected_row || t_sec["selected_row"] || t_sec["focus"] || "green"
2187
+ page_size ||= (t_sec["page_size"] || 8).to_i
2188
+
2189
+ resolved_headers = (headers || headers_arg || []).map(&:to_s)
2190
+ resolved_raw_rows = (rows || rows_arg || []).map { |r| r.is_a?(Array) ? r.map(&:to_s) : r.values.map(&:to_s) }
2191
+ headers = resolved_headers
2192
+ raw_rows = resolved_raw_rows
2193
+ filtered_rows = raw_rows.dup
2194
+ selected_idx = 0
2195
+ query = String.new("")
2196
+ sort_col = nil
2197
+ sort_asc = true
2198
+ tick = 0.0
2199
+
2200
+ calc_widths = lambda do
2201
+ col_counts = [headers.length, raw_rows.map(&:length).max || 0].max
2202
+ widths = Array.new(col_counts, 0)
2203
+ headers.each_with_index { |h, i| widths[i] = [widths[i], display_width(h)].max }
2204
+ filtered_rows.each do |row|
2205
+ row.each_with_index { |cell, i| widths[i] = [widths[i], display_width(cell)].max }
2206
+ end
2207
+ widths.map { |w| w + 2 }
2208
+ end
2209
+
2210
+ draw_table = lambda do |t_tick|
2211
+ is_rgb = tbl_color.downcase == "rgb" || tbl_color.downcase == "rainbow" || tbl_color.downcase == "chroma"
2212
+ brd_color = is_rgb ? rgb_color(t_tick, 0.0) : ansi_color(tbl_color, 1)
2213
+ hdr_color = is_rgb ? rgb_color(t_tick, 0.8) : ansi_color(header_color, 2)
2214
+ foc_color = is_rgb ? rgb_color(t_tick, 1.4) : ansi_color(focus_color, 2)
2215
+ rst = ansi_reset
2216
+
2217
+ col_w = calc_widths.call
2218
+ help_line = " ↑/↓: Moverse | Enter: Elegir | s: Ordenar | Esc: Salir"
2219
+ tot_w = [col_w.sum + (col_w.length - 1) + 4, title ? display_width(title) + 8 : 0, display_width(help_line) + 4, 46].max
2220
+ tot_w = [tot_w, terminal_width - 2].min
2221
+ inner_w = tot_w - 2
2222
+
2223
+ if inner_w < display_width(help_line)
2224
+ help_line = " ↑/↓: Mover | Enter: Ok | Esc: Salir"
2225
+ end
2226
+
2227
+ tl = border_cfg[:tl] || "#"
2228
+ tr = border_cfg[:tr] || "#"
2229
+ bl = border_cfg[:bl] || "#"
2230
+ br = border_cfg[:br] || "#"
2231
+ h_char = border_cfg[:h] || "─"
2232
+ v_char = border_cfg[:v] || "│"
2233
+
2234
+ top_str = if title && !title.empty?
2235
+ t_clean = " #{title} "
2236
+ t_len = display_width(t_clean)
2237
+ if t_len > inner_w
2238
+ t_clean = " #{title[0...[(inner_w - 6), 1].max]}... "
2239
+ t_len = display_width(t_clean)
2240
+ end
2241
+ left_len = [(inner_w - t_len) / 2, 0].max
2242
+ right_len = [inner_w - t_len - left_len, 0].max
2243
+ h_char * left_len + t_clean + h_char * right_len
2244
+ else
2245
+ h_char * inner_w
2246
+ end
2247
+
2248
+ out = String.new(CURSOR_HOME)
2249
+ out << HIDE_CURSOR
2250
+ out << "#{brd_color}#{tl}#{top_str}#{tr}#{rst}#{CLEAR_TO_EOL}\r\n"
2251
+
2252
+ if search
2253
+ s_line = " Buscar: #{query}█"
2254
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(s_line, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2255
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2256
+ end
2257
+
2258
+ hdr_cells = headers.each_with_index.map do |h, i|
2259
+ w = col_w[i] || 10
2260
+ sort_indicator = sort_col == i ? (sort_asc ? " ▲" : " ▼") : ""
2261
+ h_str = "#{h}#{sort_indicator}"
2262
+ max_c = [w - 2, 2].max
2263
+ h_str = h_str[0...[max_c - 2, 1].max] + ".." if display_width(h_str) > max_c
2264
+ pad_to_width(" #{h_str}", w)
2265
+ end
2266
+ hdr_row_str = " " + hdr_cells.join("│")
2267
+ hdr_row_str = hdr_row_str[0...inner_w] if display_width(hdr_row_str) > inner_w
2268
+ 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"
2269
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2270
+
2271
+ max_visible = page_size || 8
2272
+ total_rows = filtered_rows.length
2273
+ if total_rows == 0
2274
+ empty_msg = " (Sin registros que coincidan con '#{query}')"
2275
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(empty_msg, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2276
+ else
2277
+ start_idx = [(selected_idx - max_visible / 2), 0].max
2278
+ start_idx = [start_idx, [total_rows - max_visible, 0].max].min
2279
+ end_idx = [start_idx + max_visible - 1, total_rows - 1].min
2280
+
2281
+ if start_idx > 0
2282
+ up_str = " ▲ (+#{start_idx} arriba)"
2283
+ 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"
2284
+ end
2285
+
2286
+ (start_idx..end_idx).each do |r_i|
2287
+ row = filtered_rows[r_i]
2288
+ is_active = (r_i == selected_idx)
2289
+ prefix = is_active ? "> " : " "
2290
+
2291
+ row_cells = row.each_with_index.map do |cell, c_i|
2292
+ w = col_w[c_i] || 10
2293
+ c_str = cell.to_s
2294
+ max_c = [w - 2, 2].max
2295
+ c_str = c_str[0...[max_c - 2, 1].max] + ".." if display_width(c_str) > max_c
2296
+ pad_to_width(" #{c_str}", w)
2297
+ end
2298
+ row_str = prefix + row_cells.join("│")[1..-1].to_s
2299
+ row_str = row_str[0...inner_w] if display_width(row_str) > inner_w
2300
+
2301
+ if is_active
2302
+ 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"
2303
+ else
2304
+ out << "#{brd_color}#{v_char}#{rst}#{pad_to_width(row_str, inner_w)}#{brd_color}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2305
+ end
2306
+ end
2307
+
2308
+ remaining_down = total_rows - 1 - end_idx
2309
+ if remaining_down > 0
2310
+ down_str = " ▼ (+#{remaining_down} abajo)"
2311
+ 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"
2312
+ end
2313
+ end
2314
+
2315
+ out << "#{brd_color}#{v_char}#{h_char * inner_w}#{v_char}#{rst}#{CLEAR_TO_EOL}\r\n"
2316
+ 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"
2317
+ out << "#{brd_color}#{bl}#{h_char * inner_w}#{br}#{rst}#{CLEAR_TO_EOL}\r\n"
2318
+ out << CLEAR_TO_EOS
2319
+ output_stream.print(out)
2320
+ output_stream.flush
2321
+ end
2322
+
2323
+ loop_res = nil
2324
+ reader = lambda do |stream|
2325
+ loop do
2326
+ draw_table.call(tick)
2327
+ is_anim = color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma"
2328
+ if is_anim
2329
+ ready = false
2330
+ if stream.respond_to?(:to_io) || stream.is_a?(IO)
2331
+ begin
2332
+ res = IO.select([stream], nil, nil, 0.035)
2333
+ ready = true if res && res[0] && !res[0].empty?
2334
+ rescue StandardError
2335
+ ready = true
2336
+ end
2337
+ else
2338
+ ready = true
2339
+ end
2340
+ unless ready
2341
+ tick += 0.08
2342
+ next
2343
+ end
2344
+ end
2345
+
2346
+ key = read_key_raw(stream)
2347
+ break if key.nil? || key == "\x03" || key == "\x04"
2348
+
2349
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
2350
+ if filtered_rows.length > 0
2351
+ selected_idx = (selected_idx - 1) % filtered_rows.length
2352
+ end
2353
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
2354
+ if filtered_rows.length > 0
2355
+ selected_idx = (selected_idx + 1) % filtered_rows.length
2356
+ end
2357
+ elsif key == "\e[5~" || key == "\e[D"
2358
+ if filtered_rows.length > 0
2359
+ selected_idx = [(selected_idx - (page_size || 8)), 0].max
2360
+ end
2361
+ elsif key == "\e[6~" || key == "\e[C"
2362
+ if filtered_rows.length > 0
2363
+ selected_idx = [(selected_idx + (page_size || 8)), filtered_rows.length - 1].min
2364
+ end
2365
+ elsif key == "\r" || key == "\n"
2366
+ if filtered_rows.length > 0
2367
+ loop_res = filtered_rows[selected_idx]
2368
+ end
2369
+ break
2370
+ elsif key == "\e"
2371
+ if search && !query.empty?
2372
+ query.clear
2373
+ filtered_rows = raw_rows.dup
2374
+ selected_idx = 0
2375
+ else
2376
+ loop_res = nil
2377
+ break
2378
+ end
2379
+ elsif key == "\x7f" || key == "\b" || key == "\x08"
2380
+ if search && !query.empty?
2381
+ query.chop!
2382
+ if query.empty?
2383
+ filtered_rows = raw_rows.dup
2384
+ else
2385
+ filtered_rows = raw_rows.select { |r| r.any? { |c| c.downcase.include?(query.downcase) } }
2386
+ end
2387
+ selected_idx = 0
2388
+ end
2389
+ elsif key == "\x15"
2390
+ if search
2391
+ query.clear
2392
+ filtered_rows = raw_rows.dup
2393
+ selected_idx = 0
2394
+ end
2395
+ elsif (!search || query.empty?) && (key == "s" || key == "S")
2396
+ if sort
2397
+ sort_col = ((sort_col || -1) + 1) % [headers.length, 1].max
2398
+ filtered_rows.sort_by! { |r| r[sort_col] || "" }
2399
+ selected_idx = 0
2400
+ end
2401
+ elsif (!search || query.empty?) && (key == "q" || key == "Q")
2402
+ loop_res = nil
2403
+ break
2404
+ elsif search && key =~ /^[[:print:]]$/
2405
+ query << key
2406
+ filtered_rows = raw_rows.select { |r| r.any? { |c| c.downcase.include?(query.downcase) } }
2407
+ selected_idx = 0
2408
+ end
2409
+ end
2410
+ end
2411
+
2412
+ begin
2413
+ output_stream.print("#{HIDE_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
2414
+ if input_stream.respond_to?(:raw) && input_stream.respond_to?(:tty?) && input_stream.tty?
2415
+ input_stream.raw { |s| reader.call(s) }
2416
+ else
2417
+ reader.call(input_stream)
2418
+ end
2419
+ ensure
2420
+ output_stream.print("#{SHOW_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
2421
+ end
2422
+ loop_res
2423
+ end
2424
+
1527
2425
  def self.div(long = nil, color = "blue", level = 1, char = "─")
1528
2426
  width = long || [terminal_width - 2, 64].min
1529
2427
  if color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma"
@@ -1648,10 +2546,25 @@ class GRmenu
1648
2546
  class << self
1649
2547
  alias_method :message, :banner
1650
2548
  alias_method :logo, :banner
2549
+
2550
+ def tabs(tabs_hash, *args, **kwargs)
2551
+ new([], *args, tabs: tabs_hash, **kwargs)
2552
+ end
1651
2553
  end
1652
2554
 
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)
2555
+ 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)
2556
+ tabs_data = tabs || keyword_arguments[:tabs] || (functions.is_a?(Hash) ? functions : nil)
2557
+ if tabs_data.is_a?(Hash) && !tabs_data.empty?
2558
+ @tabs = tabs_data.keys.map(&:to_s)
2559
+ @tab_contents = tabs_data.transform_keys(&:to_s)
2560
+ @active_tab_idx = 0
2561
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
2562
+ else
2563
+ @tabs = nil
2564
+ @tab_contents = nil
2565
+ @active_tab_idx = nil
2566
+ @functions = functions.is_a?(Array) ? functions : Array(functions)
2567
+ end
1655
2568
 
1656
2569
  pos_title = positional_arguments[0]
1657
2570
  pos_style = positional_arguments[1]
@@ -1660,22 +2573,56 @@ class GRmenu
1660
2573
  @banner = (banner || keyword_arguments[:banner] || "").to_s
1661
2574
  @subtitle = (subtitle || description || keyword_arguments[:subtitle] || keyword_arguments[:description] || "").to_s
1662
2575
  @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
2576
+
2577
+ theme_menu_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, 'menu')) || {}
2578
+ th_style = theme_menu_sec['style']&.to_i
2579
+ th_bstyle = theme_menu_sec['banner_style']&.to_i
2580
+
2581
+ @style = (style || pos_style || keyword_arguments[:style] || th_style || 19).to_i
2582
+ @banner_style = (banner_style || keyword_arguments[:banner_style] || th_bstyle || 3).to_i
2583
+ @center = center.nil? ? (theme_menu_sec.key?('center') ? (theme_menu_sec['center'].to_s != 'false') : true) : center
1666
2584
  @page_size = (page_size || keyword_arguments[:page_size])&.to_i
1667
2585
  @search = search || keyword_arguments[:search] || false
1668
2586
  @columns = [(columns || keyword_arguments[:columns] || 1).to_i, 1].max
1669
2587
  @image = image || keyword_arguments[:image]
1670
2588
  @image_width = (image_width || keyword_arguments[:image_width])&.to_i
2589
+ @animate = (keyword_arguments[:animate] || (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, 'menu', 'animate')) || false).to_s
2590
+ @desc_prefix = keyword_arguments[:desc_prefix] || keyword_arguments[:description_prefix]
2591
+ @mouse = (mouse == true || keyword_arguments[:mouse] == true || mouse.to_s == 'true' || keyword_arguments[:mouse].to_s == 'true' || (theme_menu_sec['mouse'].to_s == 'true'))
2592
+
2593
+ t_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "tabs")) || {}
2594
+ @active_tab_color = (active_tab_color || keyword_arguments[:active_tab_color] || t_sec["active_tab"] || t_sec["active_tab_color"] || "yellow").to_s
2595
+ @tab_color = (tab_color || keyword_arguments[:tab_color] || t_sec["tab_color"] || t_sec["inactive_tab"] || t_sec["color"] || "gray").to_s
2596
+
1671
2597
  @query = String.new("")
1672
2598
  @index = 0
1673
2599
  @rgb_tick = 0.0
1674
2600
 
2601
+ @level = 0
2602
+ @open_level = 0
2603
+ @sub_index_1 = 0
2604
+ @sub_index_2 = 0
2605
+ @sub_1_hit_map = {}
2606
+ @sub_2_hit_map = {}
2607
+ @sub_1_col_rng = nil
2608
+ @sub_2_col_rng = nil
2609
+
2610
+ @active_panel = :main
2611
+ @sub_index = 0
2612
+ @submenu_open = false
2613
+
2614
+ @row_hit_map = {}
2615
+ @tab_ranges = {}
2616
+ @tabs_row = nil
2617
+ @up_arrow_row = nil
2618
+ @down_arrow_row = nil
2619
+ @sub_hit_map = {}
2620
+
1675
2621
  @cached_image_lines = nil
1676
2622
  @cached_image_cols = nil
1677
2623
 
1678
2624
  init_font = font || keyword_arguments[:font_style] || SetStyle.font || 1
2625
+ 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
2626
 
1680
2627
  @style_config = SetStyle.new(
1681
2628
  border: SetStyle.border.dup,
@@ -1685,7 +2632,8 @@ class GRmenu
1685
2632
  banner: SetStyle.banner.dup,
1686
2633
  subtitle: SetStyle.subtitle.dup,
1687
2634
  divider: SetStyle.divider.dup,
1688
- font: init_font
2635
+ font: init_font,
2636
+ desc_prefix: init_pfx
1689
2637
  )
1690
2638
  end
1691
2639
 
@@ -1776,10 +2724,20 @@ class GRmenu
1776
2724
  def colorize(text, color_config, phase_offset = 0.0)
1777
2725
  return text.to_s if color_config.nil? || color_config.empty?
1778
2726
 
1779
- color_name = (color_config[:color] || color_config["color"]).to_s.downcase
2727
+ color_name = (color_config[:color] || color_config["color"]).to_s.downcase.strip
1780
2728
  brightness_level = (color_config[:level] || color_config["level"] || 1).to_i
1781
2729
 
1782
- if color_name == "rgb" || color_name == "rainbow" || color_name == "chroma"
2730
+ if color_name.include?(":")
2731
+ parts = color_name.split(":")
2732
+ color_name = parts[0].strip
2733
+ brightness_level = parts[1].to_i if parts[1] && !parts[1].empty?
2734
+ end
2735
+
2736
+ is_neon_color = color_name.start_with?("neon")
2737
+ is_anim_active = is_neon_color || (@animate && ["diagonal", "linear", "fade", "rgb", "rainbow", "chroma", "neon"].include?(@animate.to_s.downcase))
2738
+ is_chroma = color_name == "rgb" || color_name == "rainbow" || color_name == "chroma" || @animate.to_s.downcase == "rgb"
2739
+
2740
+ if is_chroma
1783
2741
  tick = @rgb_tick || 0.0
1784
2742
  out = String.new("")
1785
2743
  char_count = 0
@@ -1814,6 +2772,67 @@ class GRmenu
1814
2772
  return out
1815
2773
  end
1816
2774
 
2775
+ if is_anim_active
2776
+ tick = @rgb_tick || 0.0
2777
+ base = if color_name =~ /\A#?([0-9a-f]{6})\z/i
2778
+ h = $1
2779
+ [h[0..1].to_i(16), h[2..3].to_i(16), h[4..5].to_i(16)]
2780
+ elsif color_name =~ /\A#?([0-9a-f]{3})\z/i
2781
+ h = $1
2782
+ [(h[0] * 2).to_i(16), (h[1] * 2).to_i(16), (h[2] * 2).to_i(16)]
2783
+ else
2784
+ BASE_RGB[color_name] || [255, 255, 255]
2785
+ end
2786
+
2787
+ if @animate.to_s.downcase == "fade"
2788
+ factor = (Math.sin(tick + phase_offset) + 1.0) / 2.0
2789
+ f = 0.35 + 0.65 * factor
2790
+ r = (base[0] * f).clamp(0, 255).to_i
2791
+ g = (base[1] * f).clamp(0, 255).to_i
2792
+ b = (base[2] * f).clamp(0, 255).to_i
2793
+ glow = (factor > 0.85) ? ";1" : ""
2794
+ return "\e[38;2;#{r};#{g};#{b}#{glow}m#{text}#{self.class.ansi_reset}"
2795
+ else
2796
+ out = String.new("")
2797
+ char_count = 0
2798
+ in_escape = false
2799
+ escape_buf = String.new("")
2800
+
2801
+ text.to_s.each_char do |ch|
2802
+ if ch == "\e"
2803
+ in_escape = true
2804
+ escape_buf << ch
2805
+ next
2806
+ end
2807
+ if in_escape
2808
+ escape_buf << ch
2809
+ if ch =~ /[a-zA-Z]/
2810
+ in_escape = false
2811
+ out << escape_buf
2812
+ escape_buf.clear
2813
+ end
2814
+ next
2815
+ end
2816
+
2817
+ if ch == " " || ch == "\t" || ch == "\r" || ch == "\n"
2818
+ out << ch
2819
+ else
2820
+ phase = char_count * 0.22 + phase_offset
2821
+ factor = (Math.sin(tick + phase) + 1.0) / 2.0
2822
+ f = 0.35 + 0.65 * factor
2823
+ r = (base[0] * f).clamp(0, 255).to_i
2824
+ g = (base[1] * f).clamp(0, 255).to_i
2825
+ b = (base[2] * f).clamp(0, 255).to_i
2826
+ glow = (factor > 0.85) ? ";1" : ""
2827
+ out << "\e[38;2;#{r};#{g};#{b}#{glow}m#{ch}"
2828
+ char_count += 1
2829
+ end
2830
+ end
2831
+ out << self.class.ansi_reset
2832
+ return out
2833
+ end
2834
+ end
2835
+
1817
2836
  color_code = self.class.ansi_color(color_name, brightness_level)
1818
2837
  return text.to_s unless color_code
1819
2838
 
@@ -1870,7 +2889,7 @@ class GRmenu
1870
2889
 
1871
2890
  lines << colorize("#{banner_border[:tl]}#{top_fill}#{banner_border[:tr]}", banner_color_cfg, 0.0)
1872
2891
  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)
2892
+ lines << colorize("#{banner_border[:bl]}#{bot_fill}#{banner_border[:br]}", banner_color_cfg, 0.8)
1874
2893
  end
1875
2894
  [lines, box_w]
1876
2895
  end
@@ -1908,10 +2927,115 @@ class GRmenu
1908
2927
  @cached_image_lines
1909
2928
  end
1910
2929
 
2930
+ def is_submenu_item?(action)
2931
+ if action.is_a?(Array)
2932
+ target = action[1]
2933
+ 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))
2934
+ return true if target.is_a?(GRmenu)
2935
+ elsif action.is_a?(Hash)
2936
+ return true if action[:submenu] || action["submenu"]
2937
+ end
2938
+ false
2939
+ end
2940
+
2941
+ def get_submenu_actions(action)
2942
+ if action.is_a?(Array)
2943
+ action[1].is_a?(GRmenu) ? action[1].instance_variable_get(:@functions) : action[1]
2944
+ elsif action.is_a?(Hash)
2945
+ action[:submenu] || action["submenu"]
2946
+ end
2947
+ end
2948
+
2949
+ def render_submenu_box(sub_actions, parent_title = "", is_active: false, selected_idx: 0)
2950
+ sub_names = sub_actions.map { |a| extract_name_from_action(a) }
2951
+ max_name_len = sub_names.empty? ? 10 : sub_names.map { |n| GRmenu.display_width(n) }.max
2952
+ sub_title = parent_title.to_s
2953
+ sub_w = [max_name_len + 8, GRmenu.display_width(sub_title) + 6, 20].max
2954
+ sub_w = [sub_w, 36].min
2955
+ inner_w = [sub_w - 2, 8].max
2956
+
2957
+ s_sec = (@@global_theme.is_a?(Hash) && @@global_theme.dig(:sections, "submenu")) || {}
2958
+ sub_style = (s_sec["style"] || @style || 3).to_i
2959
+ sub_border = BORDERS[sub_style] || BORDERS[3]
2960
+ h_top = sub_border[:ht] || sub_border[:h]
2961
+ h_bot = sub_border[:hb] || sub_border[:h]
2962
+ v_l = sub_border[:vl] || sub_border[:v]
2963
+ v_r = sub_border[:vr] || sub_border[:v]
2964
+
2965
+ s_brd_col = s_sec["border"] || s_sec["border_color"] || @style_config.border[:color] || "cyan"
2966
+ s_foc_col = s_sec["focus"] || s_sec["focus_color"] || @style_config.focus[:color] || "yellow"
2967
+ s_opt_col = s_sec["options"] || s_sec["options_color"] || @style_config.options[:color] || "white"
2968
+
2969
+ s_brd_cfg = { color: s_brd_col, level: 1 }
2970
+ s_foc_cfg = { color: s_foc_col, level: 2 }
2971
+ s_opt_cfg = { color: s_opt_col, level: 1 }
2972
+ s_held_cfg = { color: "yellow", level: 1 }
2973
+
2974
+ lines = []
2975
+ top_fill = build_horizontal_line(h_top, inner_w)
2976
+ bot_fill = build_horizontal_line(h_bot, inner_w)
2977
+
2978
+ top_border_line = sub_border[:tl] + top_fill + sub_border[:tr]
2979
+ lines << colorize(top_border_line, s_brd_cfg, 0.0)
2980
+
2981
+ unless sub_title.empty?
2982
+ pad_t = [inner_w - 2 - GRmenu.display_width(sub_title), 0].max
2983
+ t_padded = " " * (pad_t / 2) + sub_title + " " * (pad_t - (pad_t / 2))
2984
+ c_title = colorize(t_padded, s_foc_cfg, 0.2)
2985
+ v_l_col = colorize(v_l, s_brd_cfg, 0.2)
2986
+ v_r_col = colorize(v_r, s_brd_cfg, 0.2)
2987
+ lines << "#{v_l_col} #{c_title} #{v_r_col}"
2988
+ mid_fill = build_horizontal_line(h_top, inner_w)
2989
+ lines << colorize(v_l + mid_fill + v_r, s_brd_cfg, 0.4)
2990
+ end
2991
+
2992
+ parent_item_row = nil
2993
+ sub_names.each_with_index do |s_name, s_idx|
2994
+ is_sub = is_submenu_item?(sub_actions[s_idx])
2995
+ arrow = is_sub ? "▶" : ""
2996
+ is_selected = (s_idx == selected_idx)
2997
+ parent_item_row = lines.length if is_selected
2998
+
2999
+ prefix = is_selected ? "> " : " "
3000
+ pad_s = [inner_w - 2 - prefix.length - GRmenu.display_width(s_name) - (is_sub ? 2 : 0), 0].max
3001
+ raw_item = if is_sub
3002
+ "#{prefix}#{s_name}#{' ' * pad_s} #{arrow}"
3003
+ else
3004
+ "#{prefix}#{s_name}#{' ' * pad_s}"
3005
+ end
3006
+
3007
+ item_cfg = if is_selected
3008
+ is_active ? s_foc_cfg : s_held_cfg
3009
+ else
3010
+ s_opt_cfg
3011
+ end
3012
+
3013
+ colored_item = colorize(raw_item, item_cfg, s_idx * 0.2)
3014
+ v_l_col = colorize(v_l, s_brd_cfg, 0.2)
3015
+ v_r_col = colorize(v_r, s_brd_cfg, 0.8)
3016
+ lines << "#{v_l_col} #{colored_item} #{v_r_col}"
3017
+ end
3018
+
3019
+ bot_border_line = sub_border[:bl] + bot_fill + sub_border[:br]
3020
+ lines << colorize(bot_border_line, s_brd_cfg, 0.6)
3021
+
3022
+ [lines, sub_w, parent_item_row]
3023
+ end
3024
+
1911
3025
  def render_lines(size_max = 20)
1912
3026
  term_cols = self.class.terminal_width
1913
3027
  term_rows = self.class.terminal_height
1914
3028
  rendered_lines = []
3029
+ @row_hit_map = {}
3030
+ @tab_ranges = {}
3031
+ @tabs_row = nil
3032
+ @up_arrow_row = nil
3033
+ @down_arrow_row = nil
3034
+ @sub_hit_map = {}
3035
+ @sub_1_hit_map = {}
3036
+ @sub_2_hit_map = {}
3037
+ @sub_1_col_rng = nil
3038
+ @sub_2_col_rng = nil
1915
3039
 
1916
3040
  header_box_width = 0
1917
3041
  header_lines_count = 0
@@ -2019,6 +3143,39 @@ class GRmenu
2019
3143
  avail_w = [total_width - 4, 1].max
2020
3144
  col_w = [(avail_w - (cols - 1) * 2) / cols, 1].max
2021
3145
 
3146
+ if @tabs && !@tabs.empty?
3147
+ tab_strs = []
3148
+ raw_tab_strs = []
3149
+ @tab_ranges = {}
3150
+ @tabs.each_with_index do |t_name, t_i|
3151
+ is_active = (t_i == @active_tab_idx)
3152
+ raw_t = is_active ? "[ #{t_name} ]" : " #{t_name} "
3153
+ colored_t = if is_active
3154
+ colorize(raw_t, { color: @active_tab_color, level: 2 }, 0.0)
3155
+ else
3156
+ colorize(raw_t, { color: @tab_color, level: 1 }, 0.0)
3157
+ end
3158
+ tab_strs << colored_t
3159
+ raw_tab_strs << raw_t
3160
+ end
3161
+ raw_tabs_joined = raw_tab_strs.join(" ")
3162
+ tabs_w = GRmenu.display_width(raw_tabs_joined)
3163
+ l_tabs_pad = [(total_width - tabs_w) / 2, 0].max
3164
+ formatted_tabs = (" " * l_tabs_pad) + tab_strs.join(" ")
3165
+
3166
+ start_col = margin_left.length + l_tabs_pad
3167
+ cur_x = start_col
3168
+ @tabs.each_with_index do |_t_name, t_i|
3169
+ t_len = GRmenu.display_width(raw_tab_strs[t_i])
3170
+ @tab_ranges[t_i] = (cur_x + 1)..(cur_x + t_len)
3171
+ cur_x += t_len + 3
3172
+ end
3173
+
3174
+ @tabs_row = rendered_lines.length + 1
3175
+ rendered_lines << "#{margin_left}#{formatted_tabs}"
3176
+ rendered_lines << ""
3177
+ end
3178
+
2022
3179
  if box_border
2023
3180
  h_top = box_border[:ht] || box_border[:h]
2024
3181
  h_bot = box_border[:hb] || box_border[:h]
@@ -2055,12 +3212,14 @@ class GRmenu
2055
3212
  end
2056
3213
 
2057
3214
  if has_more_above
3215
+ @up_arrow_row = rendered_lines.length + 1
2058
3216
  up_text = "▲ (+#{start_row} #{cols > 1 ? 'filas' : 'arriba'})"
2059
3217
  pad_up = [avail_w - GRmenu.display_width(up_text), 0].max
2060
3218
  up_indicator = colorize(" " * (pad_up / 2) + up_text + " " * (pad_up - (pad_up / 2)), { color: "gray", level: 2 })
2061
3219
  rendered_lines << "#{margin_left}#{v_left} #{up_indicator} #{v_right}"
2062
3220
  end
2063
3221
 
3222
+ parent_row_idx = nil
2064
3223
  if rows_data.empty?
2065
3224
  no_res_txt = "(Sin resultados)"
2066
3225
  pad_no = [avail_w - GRmenu.display_width(no_res_txt), 0].max
@@ -2073,12 +3232,25 @@ class GRmenu
2073
3232
  item_idx = row_indices[c_idx]
2074
3233
  if item_idx
2075
3234
  op_name = all_names[item_idx]
3235
+ is_sub = is_submenu_item?(@functions[item_idx])
3236
+ arrow = is_sub ? "▶" : ""
2076
3237
  if @index == item_idx
2077
- cell_raw = "> #{op_name}"
3238
+ parent_row_idx = rendered_lines.length
3239
+ cell_raw = if is_sub
3240
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3241
+ "> #{op_name}#{' ' * pad_sub}#{arrow}"
3242
+ else
3243
+ "> #{op_name}"
3244
+ end
2078
3245
  pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2079
3246
  cells << colorize(cell_raw + (" " * pad_c), focus_color_cfg, r_idx * 0.3)
2080
3247
  else
2081
- cell_raw = " #{op_name}"
3248
+ cell_raw = if is_sub
3249
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3250
+ " #{op_name}#{' ' * pad_sub}#{arrow}"
3251
+ else
3252
+ " #{op_name}"
3253
+ end
2082
3254
  pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2083
3255
  cells << colorize(cell_raw + (" " * pad_c), options_color_cfg, r_idx * 0.2)
2084
3256
  end
@@ -2089,11 +3261,14 @@ class GRmenu
2089
3261
  row_str = cells.join(" ")
2090
3262
  pad_r = [avail_w - (col_w * cols + (cols - 1) * 2), 0].max
2091
3263
  row_padded = row_str + (" " * pad_r)
3264
+ cur_line_num = rendered_lines.length + 1
3265
+ @row_hit_map[cur_line_num] = { row_indices: row_indices, cols: cols, col_w: col_w, margin_left: margin_left.length }
2092
3266
  rendered_lines << "#{margin_left}#{v_left} #{row_padded} #{v_right}"
2093
3267
  end
2094
3268
  end
2095
3269
 
2096
3270
  if has_more_below
3271
+ @down_arrow_row = rendered_lines.length + 1
2097
3272
  remaining_below = total_rows - 1 - end_row
2098
3273
  down_text = "▼ (+#{remaining_below} #{cols > 1 ? 'filas' : 'abajo'})"
2099
3274
  pad_down = [avail_w - GRmenu.display_width(down_text), 0].max
@@ -2104,7 +3279,9 @@ class GRmenu
2104
3279
  unless active_desc.empty?
2105
3280
  separator_line = v_l_raw + mid_fill + v_r_raw
2106
3281
  rendered_lines << "#{margin_left}#{colorize(separator_line, border_color_cfg, 1.0)}"
2107
- raw_desc = "* #{active_desc}"
3282
+ pfx = (@style_config&.desc_prefix || @desc_prefix || SetStyle.desc_prefix || "[i]").to_s
3283
+ pfx = "#{pfx} " unless pfx.end_with?(" ")
3284
+ raw_desc = "#{pfx}#{active_desc}"
2108
3285
  pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
2109
3286
  desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 })
2110
3287
  rendered_lines << "#{margin_left}#{v_left} #{desc_text} #{v_right}"
@@ -2136,12 +3313,14 @@ class GRmenu
2136
3313
  end
2137
3314
 
2138
3315
  if has_more_above
3316
+ @up_arrow_row = rendered_lines.length + 1
2139
3317
  up_text = "▲ (+#{start_row} #{cols > 1 ? 'filas' : 'arriba'})"
2140
3318
  pad_up = [avail_w - GRmenu.display_width(up_text), 0].max
2141
3319
  up_indicator = colorize(" " * (pad_up / 2) + up_text + " " * (pad_up - (pad_up / 2)), { color: "gray", level: 2 })
2142
3320
  rendered_lines << "#{margin_left}#{solid_border} #{up_indicator} #{solid_border}"
2143
3321
  end
2144
3322
 
3323
+ parent_row_idx = nil
2145
3324
  if rows_data.empty?
2146
3325
  no_res_txt = "(Sin resultados)"
2147
3326
  pad_no = [avail_w - GRmenu.display_width(no_res_txt), 0].max
@@ -2154,12 +3333,25 @@ class GRmenu
2154
3333
  item_idx = row_indices[c_idx]
2155
3334
  if item_idx
2156
3335
  op_name = all_names[item_idx]
3336
+ is_sub = is_submenu_item?(@functions[item_idx])
3337
+ arrow = is_sub ? "▶" : ""
2157
3338
  if @index == item_idx
2158
- cell_raw = "> #{op_name}"
3339
+ parent_row_idx = rendered_lines.length
3340
+ cell_raw = if is_sub
3341
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3342
+ "> #{op_name}#{' ' * pad_sub}#{arrow}"
3343
+ else
3344
+ "> #{op_name}"
3345
+ end
2159
3346
  pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2160
3347
  cells << colorize(cell_raw + (" " * pad_c), focus_color_cfg, r_idx * 0.3)
2161
3348
  else
2162
- cell_raw = " #{op_name}"
3349
+ cell_raw = if is_sub
3350
+ pad_sub = [col_w - 2 - GRmenu.display_width(op_name) - 2, 0].max
3351
+ " #{op_name}#{' ' * pad_sub}#{arrow}"
3352
+ else
3353
+ " #{op_name}"
3354
+ end
2163
3355
  pad_c = [col_w - GRmenu.display_width(cell_raw), 0].max
2164
3356
  cells << colorize(cell_raw + (" " * pad_c), options_color_cfg, r_idx * 0.2)
2165
3357
  end
@@ -2170,11 +3362,14 @@ class GRmenu
2170
3362
  row_str = cells.join(" ")
2171
3363
  pad_r = [avail_w - (col_w * cols + (cols - 1) * 2), 0].max
2172
3364
  row_padded = row_str + (" " * pad_r)
3365
+ cur_line_num = rendered_lines.length + 1
3366
+ @row_hit_map[cur_line_num] = { row_indices: row_indices, cols: cols, col_w: col_w, margin_left: margin_left.length }
2173
3367
  rendered_lines << "#{margin_left}#{solid_border} #{row_padded} #{solid_border}"
2174
3368
  end
2175
3369
  end
2176
3370
 
2177
3371
  if has_more_below
3372
+ @down_arrow_row = rendered_lines.length + 1
2178
3373
  remaining_below = total_rows - 1 - end_row
2179
3374
  down_text = "▼ (+#{remaining_below} #{cols > 1 ? 'filas' : 'abajo'})"
2180
3375
  pad_down = [avail_w - GRmenu.display_width(down_text), 0].max
@@ -2184,7 +3379,9 @@ class GRmenu
2184
3379
 
2185
3380
  unless active_desc.empty?
2186
3381
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
2187
- raw_desc = "* #{active_desc}"
3382
+ pfx = (@style_config&.desc_prefix || @desc_prefix || SetStyle.desc_prefix || "[i]").to_s
3383
+ pfx = "#{pfx} " unless pfx.end_with?(" ")
3384
+ raw_desc = "#{pfx}#{active_desc}"
2188
3385
  pad_d = [avail_w - GRmenu.display_width(raw_desc), 0].max
2189
3386
  desc_text = colorize(raw_desc + (" " * pad_d), { color: "cyan", level: 1 })
2190
3387
  rendered_lines << "#{margin_left}#{solid_border} #{desc_text} #{solid_border}"
@@ -2193,6 +3390,152 @@ class GRmenu
2193
3390
  rendered_lines << "#{margin_left}#{colorize(solid_line, border_color_cfg)}"
2194
3391
  end
2195
3392
 
3393
+ if @open_level >= 1 && is_submenu_item?(@functions[@index]) && parent_row_idx
3394
+ sub_1_acts = get_submenu_actions(@functions[@index])
3395
+ if sub_1_acts && !sub_1_acts.empty?
3396
+ sub_1_title = all_names[@index] || "Submenu"
3397
+ 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)
3398
+
3399
+ sub_2_acts = nil
3400
+ sub_2_lines = nil
3401
+ sub_2_w = 0
3402
+ p_row_2 = nil
3403
+ if @open_level >= 2 && is_submenu_item?(sub_1_acts[@sub_index_1])
3404
+ sub_2_acts = get_submenu_actions(sub_1_acts[@sub_index_1])
3405
+ if sub_2_acts && !sub_2_acts.empty?
3406
+ sub_2_title = extract_name_from_action(sub_1_acts[@sub_index_1]) || "Submenu"
3407
+ 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)
3408
+ end
3409
+ end
3410
+
3411
+ if sub_2_lines
3412
+ total_3_w = margin_left.length + total_width + 2 + sub_1_w + 2 + sub_2_w
3413
+ if total_3_w <= term_cols
3414
+ sub_1_start = [[parent_row_idx - 1, 0].max, [rendered_lines.length - sub_1_lines.length, 0].max].min
3415
+ p1_abs_row = sub_1_start + (p_row_1 || 1)
3416
+ sub_2_start = [[p1_abs_row - 1, 0].max, [rendered_lines.length - sub_2_lines.length, 0].max].min
3417
+ max_3_lines = [rendered_lines.length, sub_1_start + sub_1_lines.length, sub_2_start + sub_2_lines.length].max
3418
+
3419
+ x1_start = margin_left.length + total_width + 2
3420
+ x1_end = x1_start + sub_1_w
3421
+ x2_start = x1_end + 2
3422
+ x2_end = x2_start + sub_2_w
3423
+ @sub_1_col_rng = (x1_start..x1_end)
3424
+ @sub_2_col_rng = (x2_start..x2_end)
3425
+
3426
+ sub_1_offset = sub_1_title.empty? ? 1 : 3
3427
+ sub_2_offset = sub_2_title.empty? ? 1 : 3
3428
+
3429
+ stitched_lines = []
3430
+ max_3_lines.times do |i|
3431
+ l0 = rendered_lines[i] || ("#{margin_left}#{' ' * total_width}")
3432
+
3433
+ l1 = " " * sub_1_w
3434
+ br1 = " "
3435
+ if i >= sub_1_start && i < (sub_1_start + sub_1_lines.length)
3436
+ s1_idx = i - sub_1_start
3437
+ l1 = sub_1_lines[s1_idx]
3438
+ br1 = (i == parent_row_idx) ? "──" : " "
3439
+ s1_item = s1_idx - sub_1_offset
3440
+ if s1_item >= 0 && s1_item < sub_1_acts.length
3441
+ @sub_1_hit_map[i + 1] = s1_item
3442
+ @sub_hit_map[i + 1] = s1_item
3443
+ end
3444
+ end
3445
+
3446
+ l2 = ""
3447
+ br2 = ""
3448
+ if i >= sub_2_start && i < (sub_2_start + sub_2_lines.length)
3449
+ s2_idx = i - sub_2_start
3450
+ l2 = sub_2_lines[s2_idx]
3451
+ br2 = (i == p1_abs_row) ? "──" : " "
3452
+ s2_item = s2_idx - sub_2_offset
3453
+ if s2_item >= 0 && s2_item < sub_2_acts.length
3454
+ @sub_2_hit_map[i + 1] = s2_item
3455
+ end
3456
+ end
3457
+
3458
+ stitched_lines << "#{l0}#{br1}#{l1}#{br2}#{l2}".rstrip
3459
+ end
3460
+ rendered_lines = stitched_lines
3461
+ elsif (sub_1_w + 2 + sub_2_w) <= term_cols
3462
+ p1_abs_row = (p_row_1 || 1)
3463
+ sub_2_start = [[p1_abs_row - 1, 0].max, [sub_1_lines.length - sub_2_lines.length, 0].max].min
3464
+ max_2_lines = [sub_1_lines.length, sub_2_start + sub_2_lines.length].max
3465
+
3466
+ x1_start = margin_left.length
3467
+ x1_end = x1_start + sub_1_w
3468
+ x2_start = x1_end + 2
3469
+ x2_end = x2_start + sub_2_w
3470
+ @sub_1_col_rng = (x1_start..x1_end)
3471
+ @sub_2_col_rng = (x2_start..x2_end)
3472
+
3473
+ sub_1_offset = sub_1_title.empty? ? 1 : 3
3474
+ sub_2_offset = sub_2_title.empty? ? 1 : 3
3475
+
3476
+ stitched_lines = []
3477
+ max_2_lines.times do |i|
3478
+ l1 = sub_1_lines[i] || (" " * sub_1_w)
3479
+ l2 = ""
3480
+ br2 = ""
3481
+ if i >= sub_2_start && i < (sub_2_start + sub_2_lines.length)
3482
+ s2_idx = i - sub_2_start
3483
+ l2 = sub_2_lines[s2_idx]
3484
+ br2 = (i == p1_abs_row) ? "──" : " "
3485
+ s2_item = s2_idx - sub_2_offset
3486
+ @sub_2_hit_map[i + 1] = s2_item if s2_item >= 0 && s2_item < sub_2_acts.length
3487
+ end
3488
+ s1_item = i - sub_1_offset
3489
+ if s1_item >= 0 && s1_item < sub_1_acts.length
3490
+ @sub_1_hit_map[i + 1] = s1_item
3491
+ @sub_hit_map[i + 1] = s1_item
3492
+ end
3493
+ stitched_lines << "#{margin_left}#{l1}#{br2}#{l2}".rstrip
3494
+ end
3495
+ rendered_lines = stitched_lines
3496
+ else
3497
+ active_box = (@level == 2) ? sub_2_lines : sub_1_lines
3498
+ rendered_lines = active_box.map { |l| "#{margin_left}#{l}" }
3499
+ end
3500
+ else
3501
+ total_2_w = margin_left.length + total_width + 2 + sub_1_w
3502
+ if total_2_w <= term_cols
3503
+ sub_start_line = [[parent_row_idx - 1, 0].max, [rendered_lines.length - sub_1_lines.length, 0].max].min
3504
+ max_total_lines = [rendered_lines.length, sub_start_line + sub_1_lines.length].max
3505
+
3506
+ x1_start = margin_left.length + total_width + 2
3507
+ x1_end = x1_start + sub_1_w
3508
+ @sub_1_col_rng = (x1_start..x1_end)
3509
+
3510
+ sub_item_offset = sub_1_title.empty? ? 1 : 3
3511
+
3512
+ stitched_lines = []
3513
+ max_total_lines.times do |i|
3514
+ main_line = rendered_lines[i] || ("#{margin_left}#{' ' * total_width}")
3515
+ if i >= sub_start_line && i < (sub_start_line + sub_1_lines.length)
3516
+ sub_idx = i - sub_start_line
3517
+ sub_l = sub_1_lines[sub_idx]
3518
+ bridge = (i == parent_row_idx) ? "──" : " "
3519
+ stitched_lines << "#{main_line}#{bridge}#{sub_l}"
3520
+
3521
+ s_item_idx = sub_idx - sub_item_offset
3522
+ if s_item_idx >= 0 && s_item_idx < sub_1_acts.length
3523
+ @sub_1_hit_map[i + 1] = s_item_idx
3524
+ @sub_hit_map[i + 1] = s_item_idx
3525
+ end
3526
+ else
3527
+ stitched_lines << main_line
3528
+ end
3529
+ end
3530
+ rendered_lines = stitched_lines
3531
+ else
3532
+ active_box = (@level == 1) ? sub_1_lines : rendered_lines
3533
+ rendered_lines = active_box.map { |l| "#{margin_left}#{l}" }
3534
+ end
3535
+ end
3536
+ end
3537
+ end
3538
+
2196
3539
  rendered_lines
2197
3540
  end
2198
3541
 
@@ -2216,7 +3559,180 @@ class GRmenu
2216
3559
  end
2217
3560
  end
2218
3561
 
3562
+ def has_active_animation?
3563
+ return true if @animate && ["diagonal", "linear", "fade", "rgb", "rainbow", "chroma", "neon"].include?(@animate.to_s.downcase)
3564
+ return true if has_rgb_animation?
3565
+ return true if @active_tab_color && @active_tab_color.to_s.downcase.start_with?("neon")
3566
+ configs = [
3567
+ @style_config.border,
3568
+ @style_config.options,
3569
+ @style_config.focus,
3570
+ @style_config.title,
3571
+ @style_config.banner,
3572
+ @style_config.subtitle,
3573
+ @style_config.divider
3574
+ ]
3575
+ configs.any? do |c|
3576
+ if c.is_a?(Hash)
3577
+ val = (c[:color] || c["color"]).to_s.downcase.strip
3578
+ val.start_with?("neon")
3579
+ else
3580
+ false
3581
+ end
3582
+ end
3583
+ end
3584
+
3585
+ def style(css_content)
3586
+ parsed = self.class.parse_config_text(css_content)
3587
+ m = ((parsed[:sections] && parsed[:sections]["menu"]) || {}).merge(parsed[:global] || {})
3588
+ if m["style"]
3589
+ @style = m["style"].to_i
3590
+ @border_config = BORDERS[@style] || BORDERS[3]
3591
+ end
3592
+ @banner_style = m["banner_style"].to_i if m["banner_style"]
3593
+ @animate = m["animate"].to_s if m["animate"]
3594
+ @center = (m["center"].to_s != "false") if m.key?("center")
3595
+ if m["border"] || m["border_color"]
3596
+ c, l = self.class.extract_color_and_level(m["border"] || m["border_color"], 1)
3597
+ @style_config.border(c, l)
3598
+ end
3599
+ if m["options"] || m["options_color"]
3600
+ c, l = self.class.extract_color_and_level(m["options"] || m["options_color"], 1)
3601
+ @style_config.options(c, l)
3602
+ end
3603
+ if m["focus"] || m["focus_color"]
3604
+ c, l = self.class.extract_color_and_level(m["focus"] || m["focus_color"], 2)
3605
+ @style_config.focus(c, l)
3606
+ end
3607
+ if m["title"] || m["title_color"]
3608
+ c, l = self.class.extract_color_and_level(m["title"] || m["title_color"], 2)
3609
+ @style_config.title(c, l)
3610
+ end
3611
+ if m["banner"] || m["banner_color"]
3612
+ c, l = self.class.extract_color_and_level(m["banner"] || m["banner_color"], 2)
3613
+ @style_config.banner(c, l)
3614
+ end
3615
+ if m["subtitle"] || m["subtitle_color"]
3616
+ c, l = self.class.extract_color_and_level(m["subtitle"] || m["subtitle_color"], 1)
3617
+ @style_config.subtitle(c, l)
3618
+ end
3619
+ if m["divider"] || m["divider_color"]
3620
+ c, l = self.class.extract_color_and_level(m["divider"] || m["divider_color"], 1)
3621
+ @style_config.divider(c, l)
3622
+ end
3623
+ if m["desc_prefix"] || m["description_prefix"] || m["prefix"]
3624
+ @style_config.desc_prefix(m["desc_prefix"] || m["description_prefix"] || m["prefix"])
3625
+ end
3626
+ @style_config.font(m["font"].to_i) if m["font"]
3627
+ @mouse = (m["mouse"].to_s == "true") if m.key?("mouse")
3628
+ if parsed[:sections] && parsed[:sections]["tabs"]
3629
+ t_sec = parsed[:sections]["tabs"]
3630
+ @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"])
3631
+ @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"])
3632
+ end
3633
+ self
3634
+ end
3635
+
3636
+ def export_config(path = nil)
3637
+ if path.nil?
3638
+ caller_loc = caller_locations.find { |c| !c.path.include?(__FILE__) }
3639
+ base = caller_loc ? caller_loc.path.sub(/\.rb$/, '') : "theme"
3640
+ path = "#{base}.gr"
3641
+ end
3642
+ b_cfg = @style_config&.border || SetStyle.border
3643
+ t_cfg = @style_config&.title || SetStyle.title
3644
+ f_cfg = @style_config&.focus || SetStyle.focus
3645
+ o_cfg = @style_config&.options || SetStyle.options
3646
+ bn_cfg = @style_config&.banner || SetStyle.banner
3647
+ s_cfg = @style_config&.subtitle || SetStyle.subtitle
3648
+ d_cfg = @style_config&.divider || SetStyle.divider
3649
+ dp_val = @style_config&.desc_prefix || SetStyle.desc_prefix
3650
+
3651
+ lines = ["GRmenu::config<-1->", ""]
3652
+ lines << "@theme:: \"#{File.basename(path, '.gr').capitalize}\""
3653
+ lines << "@author:: \"grcode\""
3654
+ lines << "@version:: \"1.0\""
3655
+ lines << ""
3656
+ lines << "<<menu"
3657
+ lines << " style:: #{@style || 3}"
3658
+ lines << " banner_style:: #{@banner_style || 3}"
3659
+ lines << " font:: #{@style_config&.font || SetStyle.font}"
3660
+ lines << " animate:: #{@animate || 'rgb'}"
3661
+ lines << " center:: #{@center.nil? ? true : @center}"
3662
+ lines << " desc_prefix:: #{dp_val}"
3663
+ lines << " mouse:: #{@mouse}" if @mouse
3664
+ lines << " border:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3665
+ lines << " title:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3666
+ lines << " focus:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3667
+ lines << " options:: #{o_cfg[:color]}:#{o_cfg[:level]}"
3668
+ lines << " banner:: #{bn_cfg[:color]}:#{bn_cfg[:level]}"
3669
+ lines << " subtitle:: #{s_cfg[:color]}:#{s_cfg[:level]}"
3670
+ lines << " divider:: #{d_cfg[:color]}:#{d_cfg[:level]}"
3671
+ lines << ">>"
3672
+ lines << ""
3673
+ lines << "<<submenu"
3674
+ lines << " style:: #{@style || 3}"
3675
+ lines << " border:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3676
+ lines << " focus:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3677
+ lines << " options:: #{o_cfg[:color]}:#{o_cfg[:level]}"
3678
+ lines << ">>"
3679
+ lines << ""
3680
+ lines << "<<tabs"
3681
+ lines << " active_tab:: #{@active_tab_color || 'yellow'}:2"
3682
+ lines << " tab_color:: #{@tab_color || 'gray'}:1"
3683
+ lines << ">>"
3684
+ lines << ""
3685
+ lines << "<<input"
3686
+ lines << " style:: 3"
3687
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3688
+ lines << " title_color:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3689
+ lines << " label_color:: white:1"
3690
+ lines << ">>"
3691
+ lines << ""
3692
+ lines << "<<table"
3693
+ lines << " style:: #{@style || 3}"
3694
+ lines << " header_color:: yellow:2"
3695
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3696
+ lines << " selected_row:: #{f_cfg[:color]}:#{f_cfg[:level]}"
3697
+ lines << " row_color:: white:1"
3698
+ lines << " zebra_striping:: true"
3699
+ lines << ">>"
3700
+ lines << ""
3701
+ lines << "<<card"
3702
+ lines << " style:: 7"
3703
+ lines << " border_color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3704
+ lines << " title_color:: #{t_cfg[:color]}:#{t_cfg[:level]}"
3705
+ lines << " content_color:: white:1"
3706
+ lines << ">>"
3707
+ lines << ""
3708
+ lines << "<<slider"
3709
+ lines << " style:: #{@style || 3}"
3710
+ lines << " color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3711
+ lines << " fill_char:: █"
3712
+ lines << " empty_char:: ░"
3713
+ lines << ">>"
3714
+ lines << ""
3715
+ lines << "<<checkbox"
3716
+ lines << " style:: #{@style || 3}"
3717
+ lines << " color:: #{b_cfg[:color]}:#{b_cfg[:level]}"
3718
+ lines << " checked_mark:: [X]"
3719
+ lines << " unchecked_mark:: [ ]"
3720
+ lines << ">>"
3721
+ lines << ""
3722
+ File.write(path, lines.join("\n") + "\n")
3723
+ path
3724
+ end
3725
+ alias_method :export_theme, :export_config
3726
+
2219
3727
  def draw(size_max: 20, min_width: nil)
3728
+ if ARGV.any? { |a| ["-theme", "--theme", "-ex", "--export-theme"].include?(a.to_s.downcase) }
3729
+ out_idx = ARGV.index { |a| ["-o", "--out", "--output"].include?(a.to_s.downcase) }
3730
+ target_file = out_idx ? ARGV[out_idx + 1] : "tema_exportado.gr"
3731
+ export_config(target_file)
3732
+ Kernel.puts Color.bright_green("[OK] Tema exportado exitosamente a: #{target_file}")
3733
+ exit(0)
3734
+ end
3735
+
2220
3736
  target_width = min_width || size_max || 20
2221
3737
  action_to_execute = nil
2222
3738
 
@@ -2224,6 +3740,12 @@ class GRmenu
2224
3740
 
2225
3741
  begin
2226
3742
  Kernel.print("#{HIDE_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
3743
+ Kernel.print(ENABLE_MOUSE) if @mouse
3744
+
3745
+ if @animate && !["false", "rgb", "", "nil"].include?(@animate.downcase)
3746
+ intro_lines = render_lines(target_width)
3747
+ self.class.animate_render(intro_lines, @animate)
3748
+ end
2227
3749
 
2228
3750
  if is_tty
2229
3751
  $stdin.raw do |raw_input_stream|
@@ -2233,6 +3755,7 @@ class GRmenu
2233
3755
  action_to_execute = run_interactive_loop($stdin, target_width)
2234
3756
  end
2235
3757
  ensure
3758
+ Kernel.print(DISABLE_MOUSE) if @mouse
2236
3759
  Kernel.print(SHOW_CURSOR)
2237
3760
  end
2238
3761
 
@@ -2243,6 +3766,7 @@ class GRmenu
2243
3766
  Kernel.print(CLEAR_SCREEN_SEQUENCE)
2244
3767
  end
2245
3768
  rescue Interrupt
3769
+ Kernel.print(DISABLE_MOUSE) if @mouse
2246
3770
  Kernel.print("#{SHOW_CURSOR}#{CLEAR_SCREEN_SEQUENCE}")
2247
3771
  nil
2248
3772
  end
@@ -2266,7 +3790,7 @@ class GRmenu
2266
3790
  @rgb_tick = 0.0
2267
3791
  draw_frame(target_width)
2268
3792
 
2269
- animating = has_rgb_animation?
3793
+ animating = has_active_animation?
2270
3794
 
2271
3795
  while true
2272
3796
  if animating
@@ -2292,6 +3816,255 @@ class GRmenu
2292
3816
  key = read_single_key(input_stream)
2293
3817
  break if key.nil? || key == "\x03" || key == "\x04"
2294
3818
 
3819
+ if key =~ /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
3820
+ btn = $1.to_i
3821
+ col = $2.to_i
3822
+ row = $3.to_i
3823
+ act = $4
3824
+
3825
+ if btn == 64
3826
+ if @level == 2 && @open_level >= 2 && is_submenu_item?(@functions[@index])
3827
+ s1_acts = get_submenu_actions(@functions[@index])
3828
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
3829
+ @sub_index_2 = (@sub_index_2 - 1) % s2_acts.length if s2_acts && !s2_acts.empty?
3830
+ elsif @level == 1 && @open_level >= 1 && is_submenu_item?(@functions[@index])
3831
+ s1_acts = get_submenu_actions(@functions[@index])
3832
+ @sub_index_1 = (@sub_index_1 - 1) % s1_acts.length if s1_acts && !s1_acts.empty?
3833
+ @sub_index_2 = 0
3834
+ else
3835
+ move_up
3836
+ @sub_index_1 = 0
3837
+ @sub_index_2 = 0
3838
+ end
3839
+ draw_frame(target_width)
3840
+ next
3841
+ elsif btn == 65
3842
+ if @level == 2 && @open_level >= 2 && is_submenu_item?(@functions[@index])
3843
+ s1_acts = get_submenu_actions(@functions[@index])
3844
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
3845
+ @sub_index_2 = (@sub_index_2 + 1) % s2_acts.length if s2_acts && !s2_acts.empty?
3846
+ elsif @level == 1 && @open_level >= 1 && is_submenu_item?(@functions[@index])
3847
+ s1_acts = get_submenu_actions(@functions[@index])
3848
+ @sub_index_1 = (@sub_index_1 + 1) % s1_acts.length if s1_acts && !s1_acts.empty?
3849
+ @sub_index_2 = 0
3850
+ else
3851
+ move_down
3852
+ @sub_index_1 = 0
3853
+ @sub_index_2 = 0
3854
+ end
3855
+ draw_frame(target_width)
3856
+ next
3857
+ elsif btn == 0 && act == "M"
3858
+ if @tabs && !@tabs.empty? && @tabs_row && row == @tabs_row
3859
+ clicked_tab = @tab_ranges.find { |_idx, rng| rng.cover?(col) }
3860
+ if clicked_tab
3861
+ @active_tab_idx = clicked_tab[0]
3862
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
3863
+ @index = 0
3864
+ @level = 0
3865
+ @open_level = 0
3866
+ @sub_index_1 = 0
3867
+ @sub_index_2 = 0
3868
+ @active_panel = :main
3869
+ @submenu_open = false
3870
+ draw_frame(target_width)
3871
+ next
3872
+ end
3873
+ end
3874
+
3875
+ if @up_arrow_row && row == @up_arrow_row
3876
+ move_up
3877
+ draw_frame(target_width)
3878
+ next
3879
+ end
3880
+
3881
+ if @down_arrow_row && row == @down_arrow_row
3882
+ move_down
3883
+ draw_frame(target_width)
3884
+ next
3885
+ end
3886
+
3887
+ if @open_level >= 2 && @sub_2_col_rng && @sub_2_col_rng.cover?(col) && @sub_2_hit_map[row]
3888
+ s2_clicked = @sub_2_hit_map[row]
3889
+ s1_acts = get_submenu_actions(@functions[@index])
3890
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
3891
+ if s2_acts && s2_clicked < s2_acts.length
3892
+ if @level == 2 && @sub_index_2 == s2_clicked
3893
+ return s2_acts[s2_clicked]
3894
+ else
3895
+ @level = 2
3896
+ @sub_index_2 = s2_clicked
3897
+ draw_frame(target_width)
3898
+ next
3899
+ end
3900
+ end
3901
+ end
3902
+
3903
+ if @open_level >= 1 && @sub_1_col_rng && @sub_1_col_rng.cover?(col) && @sub_1_hit_map[row]
3904
+ s1_clicked = @sub_1_hit_map[row]
3905
+ s1_acts = get_submenu_actions(@functions[@index])
3906
+ if s1_acts && s1_clicked < s1_acts.length
3907
+ t_act = s1_acts[s1_clicked]
3908
+ if @level == 1 && @sub_index_1 == s1_clicked
3909
+ if is_submenu_item?(t_act)
3910
+ @open_level = 2
3911
+ @level = 2
3912
+ @sub_index_2 = 0
3913
+ draw_frame(target_width)
3914
+ next
3915
+ else
3916
+ return t_act
3917
+ end
3918
+ else
3919
+ @level = 1
3920
+ @sub_index_1 = s1_clicked
3921
+ if is_submenu_item?(t_act)
3922
+ @open_level = 2
3923
+ @sub_index_2 = 0
3924
+ else
3925
+ @open_level = 1
3926
+ end
3927
+ draw_frame(target_width)
3928
+ next
3929
+ end
3930
+ end
3931
+ end
3932
+
3933
+ if @row_hit_map && @row_hit_map[row]
3934
+ hit = @row_hit_map[row]
3935
+ inner_x = col - hit[:margin_left] - 3
3936
+ if inner_x >= 0
3937
+ c_idx = (inner_x / (hit[:col_w] + 2)).to_i
3938
+ c_idx = [[c_idx, 0].max, hit[:cols] - 1].min
3939
+ clicked_item = hit[:row_indices][c_idx]
3940
+ if clicked_item
3941
+ if @index == clicked_item
3942
+ if is_submenu_item?(@functions[clicked_item])
3943
+ @open_level = 1
3944
+ @level = 1
3945
+ @sub_index_1 = 0
3946
+ @sub_index_2 = 0
3947
+ @active_panel = :sub
3948
+ @submenu_open = true
3949
+ draw_frame(target_width)
3950
+ next
3951
+ else
3952
+ return @functions[clicked_item]
3953
+ end
3954
+ else
3955
+ @index = clicked_item
3956
+ @level = 0
3957
+ @active_panel = :main
3958
+ if is_submenu_item?(@functions[clicked_item])
3959
+ @open_level = 1
3960
+ @sub_index_1 = 0
3961
+ @sub_index_2 = 0
3962
+ @submenu_open = true
3963
+ else
3964
+ @open_level = 0
3965
+ @submenu_open = false
3966
+ end
3967
+ draw_frame(target_width)
3968
+ next
3969
+ end
3970
+ end
3971
+ end
3972
+ end
3973
+ end
3974
+ next
3975
+ end
3976
+
3977
+ if @tabs && !@tabs.empty?
3978
+ if key == "\t"
3979
+ @active_tab_idx = (@active_tab_idx + 1) % @tabs.length
3980
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
3981
+ @index = 0
3982
+ @level = 0
3983
+ @open_level = 0
3984
+ @sub_index_1 = 0
3985
+ @sub_index_2 = 0
3986
+ @active_panel = :main
3987
+ @submenu_open = false
3988
+ draw_frame(target_width)
3989
+ next
3990
+ elsif key == "\e[Z"
3991
+ @active_tab_idx = (@active_tab_idx - 1) % @tabs.length
3992
+ @functions = @tab_contents[@tabs[@active_tab_idx]] || []
3993
+ @index = 0
3994
+ @level = 0
3995
+ @open_level = 0
3996
+ @sub_index_1 = 0
3997
+ @sub_index_2 = 0
3998
+ @active_panel = :main
3999
+ @submenu_open = false
4000
+ draw_frame(target_width)
4001
+ next
4002
+ end
4003
+ end
4004
+
4005
+ if @level == 2 && @open_level >= 2 && is_submenu_item?(@functions[@index])
4006
+ s1_acts = get_submenu_actions(@functions[@index])
4007
+ s2_acts = get_submenu_actions(s1_acts[@sub_index_1]) if s1_acts
4008
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
4009
+ @sub_index_2 = (@sub_index_2 - 1) % s2_acts.length if s2_acts && !s2_acts.empty?
4010
+ draw_frame(target_width)
4011
+ next
4012
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
4013
+ @sub_index_2 = (@sub_index_2 + 1) % s2_acts.length if s2_acts && !s2_acts.empty?
4014
+ draw_frame(target_width)
4015
+ next
4016
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "\e"
4017
+ @open_level = 1
4018
+ @level = 1
4019
+ draw_frame(target_width)
4020
+ next
4021
+ elsif key == "\r" || key == "\n"
4022
+ return s2_acts[@sub_index_2] if s2_acts && @sub_index_2 < s2_acts.length
4023
+ end
4024
+ end
4025
+
4026
+ if @level == 1 && @open_level >= 1 && is_submenu_item?(@functions[@index])
4027
+ s1_acts = get_submenu_actions(@functions[@index])
4028
+ if key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
4029
+ @sub_index_1 = (@sub_index_1 - 1) % s1_acts.length if s1_acts && !s1_acts.empty?
4030
+ @sub_index_2 = 0
4031
+ draw_frame(target_width)
4032
+ next
4033
+ elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
4034
+ @sub_index_1 = (@sub_index_1 + 1) % s1_acts.length if s1_acts && !s1_acts.empty?
4035
+ @sub_index_2 = 0
4036
+ draw_frame(target_width)
4037
+ next
4038
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K" || key == "\e"
4039
+ @open_level = 0
4040
+ @level = 0
4041
+ @active_panel = :main
4042
+ @submenu_open = false
4043
+ draw_frame(target_width)
4044
+ next
4045
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M"
4046
+ cur_l1 = s1_acts[@sub_index_1] if s1_acts
4047
+ if is_submenu_item?(cur_l1)
4048
+ @open_level = 2
4049
+ @level = 2
4050
+ @sub_index_2 = 0
4051
+ draw_frame(target_width)
4052
+ next
4053
+ end
4054
+ elsif key == "\r" || key == "\n"
4055
+ cur_l1 = s1_acts[@sub_index_1] if s1_acts
4056
+ if is_submenu_item?(cur_l1)
4057
+ @open_level = 2
4058
+ @level = 2
4059
+ @sub_index_2 = 0
4060
+ draw_frame(target_width)
4061
+ next
4062
+ else
4063
+ return cur_l1
4064
+ end
4065
+ end
4066
+ end
4067
+
2295
4068
  if !@search && (key == "q" || key == "Q")
2296
4069
  break
2297
4070
  end
@@ -2307,16 +4080,38 @@ class GRmenu
2307
4080
  end
2308
4081
  elsif key == "\e[A" || key == "\eOA" || key == "\xe0H" || key == "\x00H"
2309
4082
  move_up
4083
+ @sub_index_1 = 0
4084
+ @sub_index_2 = 0
2310
4085
  draw_frame(target_width)
2311
4086
  elsif key == "\e[B" || key == "\eOB" || key == "\xe0P" || key == "\x00P"
2312
4087
  move_down
4088
+ @sub_index_1 = 0
4089
+ @sub_index_2 = 0
2313
4090
  draw_frame(target_width)
2314
4091
  elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" || key == "\x00K"
2315
- move_left
2316
- draw_frame(target_width)
4092
+ if @open_level > 0
4093
+ @open_level = 0
4094
+ @level = 0
4095
+ @active_panel = :main
4096
+ @submenu_open = false
4097
+ draw_frame(target_width)
4098
+ else
4099
+ move_left
4100
+ draw_frame(target_width)
4101
+ end
2317
4102
  elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == "\x00M"
2318
- move_right
2319
- draw_frame(target_width)
4103
+ if is_submenu_item?(@functions[@index])
4104
+ @open_level = 1
4105
+ @level = 1
4106
+ @sub_index_1 = 0
4107
+ @sub_index_2 = 0
4108
+ @active_panel = :sub
4109
+ @submenu_open = true
4110
+ draw_frame(target_width)
4111
+ else
4112
+ move_right
4113
+ draw_frame(target_width)
4114
+ end
2320
4115
  elsif key == "\x7f" || key == "\b" || key == "\x08"
2321
4116
  if @search && !@query.empty?
2322
4117
  @query.chop!
@@ -2333,7 +4128,19 @@ class GRmenu
2333
4128
  end
2334
4129
  elsif key == "\r" || key == "\n"
2335
4130
  matching = current_matching_indices
2336
- return @functions[@index] if matching.include?(@index)
4131
+ if matching.include?(@index)
4132
+ if is_submenu_item?(@functions[@index])
4133
+ @open_level = 1
4134
+ @level = 1
4135
+ @sub_index_1 = 0
4136
+ @sub_index_2 = 0
4137
+ @active_panel = :sub
4138
+ @submenu_open = true
4139
+ draw_frame(target_width)
4140
+ else
4141
+ return @functions[@index]
4142
+ end
4143
+ end
2337
4144
  elsif @search && key =~ /^[[:print:]]$/
2338
4145
  @query << key
2339
4146
  matching = current_matching_indices